React Avancé

Les Architectures React Haute Performance : Maîtriser le Rendu, la Réconciliation et l'Optimisation Avancée

Plongez dans les mécanismes internes de React pour architécuter des applications ultra-performantes. Découvrez la réconciliation, les Hooks avancés et les patterns d'optimisation qui font la différence entre une app sluggish et une expérience fluide.

Preparetoi.academy 60 min

1. La Réconciliation et l'Algorithme de Diffing : Comprendre le Cœur de React

Définition
La réconciliation est le processus fondamental par lequel React compare l'état actuel du Virtual DOM avec le nouvel état pour déterminer quels changements appliquer au DOM réel. L'algorithme de diffing utilise une heuristique intelligente pour minimiser les opérations coûteuses.

Explication approfondie
React utilise un algorithme de réconciliation sophistiqué pour résoudre le problème NP-complet de trouver la plus petite séquence de transformations. Au lieu d'une complexité O(n³), React atteint O(n) grâce à deux heuristiques clés : (1) les éléments de types différents produisent des arbres différents, et (2) les clés stables permettent d'identifier les éléments à travers les rendus.

Quand React compare deux arbres, il examine d'abord le type d'élément. Si le type change (div → span), React détruit l'ancienne branche et crée une nouvelle. Pour les éléments du même type, React compare les props et met à jour seulement celles qui ont changé. La clé est critique : sans clé unique dans les listes, React peut associer incorrectement les éléments, causant des bugs subtils et des rendus inutiles.

Le Fiber Architecture, introduit en React 16, révolutionne cette réconciliation en la décomposant en unités de travail petites et interruptibles. Cela permet au navigateur de traiter les entrées utilisateur pendant que React travaille, maintenant l'interactivité même avec de grandes mises à jour.

// Exemple : Impact critique des clés
// ❌ MAUVAIS : Index comme clé
function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={index}>{todo.text}</li>
      ))}
    </ul>
  );
}

// ✅ BON : ID stable comme clé
function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

// Démonstration du problème avec index :
const [items, setItems] = React.useState([
  { id: 1, text: 'Apprendre React' },
  { id: 2, text: 'Maîtriser Fiber' }
]);

// Si on ajoute un élément au début :
// key=0 pointe maintenant vers un nouvel élément !
// Les états locaux des composants enfants se décalent

function DemoFiber() {
  const [todos, setTodos] = React.useState([
    { id: 'a', text: 'Task 1', status: 'pending' }
  ]);

  const addTodo = () => {
    setTodos([
      { id: 'd-' + Date.now(), text: 'New Task', status: 'pending' },
      ...todos
    ]);
  };

  return (
    <>
      <button onClick={addTodo}>Ajouter</button>
      <ul>
        {todos.map(todo => (
          <TodoItem key={todo.id} todo={todo} />
        ))}
      </ul>
    </>
  );
}

// Diagnostic avec React DevTools Profiler
import { Profiler } from 'react';

function onRenderCallback(
  id, // ID du composant
  phase, // "mount" ou "update"
  actualDuration, // Temps de rendu
  baseDuration, // Temps non mémoïsé
  startTime, // Quand React a commencé
  commitTime, // Quand React a appliqué
  interactions // Set d'interactions associées
) {
  console.log(`${id} (${phase}) took ${actualDuration}ms`);
}

<Profiler id="App" onRender={onRenderCallback}>
  <App />
</Profiler>
Aspect Comportement avec index comme clé Comportement avec ID stable
Insertion au début États décalés, bugs subtils Nouvel élément avec nouvel état
Suppression Mauvaise association d'éléments Élément correct supprimé
Réorganisation Données incohérentes Comportement prévisible
Performance Rendus inutiles potentiels Optimisation efficace
Débogage Extrêmement difficile Traçable et prévisible

Astuce d'expert
Utilisez toujours des IDs métier stables et uniques. Si vos données ne les ont pas, générez-les une seule fois à la source (serveur) plutôt que côté client. Pour les listes dynamiques complexes, considérez l'utilisation d'une bibliothèque comme immer pour les mises à jour structurées.

Attention critique
Ne pas utiliser l'index comme clé dans les listes dynamiques est une source principale de bugs React insidieux. Les tests passent, le code "fonctionne", mais les états sont décalés silencieusement. Cette erreur apparaît en production avec des actions utilisateur complexes. Configurez une règle ESLint stricte pour les interdire.


2. Hooks Avancés et Custom Hooks : Patterns d'Optimisation Complexes

Définition
Les Hooks avancés (useMemo, useCallback, useReducer, useRef, useContext) permettent de contrôler précisément le rendu, la mémorisation et la gestion d'état. Les custom Hooks encapsulent cette logique en abstractions réutilisables.

Explication approfondie
useMemo et useCallback sont les outils clés d'optimisation, mais leur utilisation excessive crée des problèmes de maintenance. useMemo mémoïse une valeur calculée coûteuse, tandis que useCallback mémoïse une fonction. Tous deux comparent les dépendances : si les dépendances ne changent pas, la valeur précédente est retournée, évitant les recalculs.

useReducer offre une gestion d'état prévisible et testable pour des états complexes. Contrairement à useState qui invite à des mises à jour dispersées, useReducer centralise la logique dans un reducer pur, facilitant le debugging et les tests.

useRef crée une référence mutable persistante à travers les rendus sans déclencher de rendu. C'est crucial pour accéder directement aux éléments DOM, stocker des timers, ou maintenir des valeurs privées du composant.

La composition de Hooks crée des patterns puissants. Un custom Hook peut combiner plusieurs Hooks standard pour implémenter une logique métier complexe. Le pattern de "custom Hook composition" permet de construire des couches d'abstraction élégantes tout en maintenant la simplicité.

// ❌ Mauvaise utilisation de useMemo (overhead inutile)
function ExpensiveComponent({ data }) {
  const processedData = useMemo(() => {
    // Cette opération est triviale !
    return data.filter(item => item.active);
  }, [data]);
  
  return <div>{processedData.length}</div>;
}

// ✅ Utilisation judicieuse de useMemo
function HeavyComputation({ dataset, threshold }) {
  const filtered = useMemo(() => {
    console.time('filter');
    const result = dataset
      .filter(item => item.value > threshold)
      .map(item => ({
        ...item,
        computed: expensiveAlgorithm(item.value)
      }));
    console.timeEnd('filter');
    return result;
  }, [dataset, threshold]);
  
  return <div>{filtered.length} items</div>;
}

// Custom Hook : Gestion d'état avec localStorage synchronisé
function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = React.useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  const setValue = React.useCallback(value => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  }, [key, storedValue]);

  return [storedValue, setValue];
}

// Utilisation
function SettingsPanel() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Thème actuel: {theme}
    </button>
  );
}

// Custom Hook avancé : Fetch avec cache et retry
function useFetch(url, options = {}) {
  const cacheRef = React.useRef(new Map());
  const [state, dispatch] = React.useReducer(
    (state, action) => {
      switch (action.type) {
        case 'LOADING':
          return { ...state, loading: true, error: null };
        case 'SUCCESS':
          return { loading: false, data: action.payload, error: null };
        case 'ERROR':
          return { loading: false, data: null, error: action.payload };
        default:
          return state;
      }
    },
    { loading: false, data: null, error: null }
  );

  const fetchData = React.useCallback(async () => {
    if (cacheRef.current.has(url)) {
      dispatch({ type: 'SUCCESS', payload: cacheRef.current.get(url) });
      return;
    }

    dispatch({ type: 'LOADING' });
    let lastError;
    
    for (let i = 0; i < (options.retries || 3); i++) {
      try {
        const response = await fetch(url);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        
        const data = await response.json();
        cacheRef.current.set(url, data);
        dispatch({ type: 'SUCCESS', payload: data });
        return;
      } catch (error) {
        lastError = error;
        await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
      }
    }
    
    dispatch({ type: 'ERROR', payload: lastError });
  }, [url, options.retries]);

  React.useEffect(() => {
    fetchData();
  }, [fetchData]);

  return { ...state, refetch: fetchData };
}

// Pattern : useReducer pour gestion d'état complexe
const formInitialState = {
  values: { name: '', email: '', password: '' },
  errors: {},
  touched: {},
  isSubmitting: false,
};

function formReducer(state, action) {
  switch (action.type) {
    case 'SET_FIELD':
      return {
        ...state,
        values: { ...state.values, [action.field]: action.value },
        touched: { ...state.touched, [action.field]: true },
      };
    case 'SET_ERROR':
      return {
        ...state,
        errors: { ...state.errors, [action.field]: action.error },
      };
    case 'SUBMIT_START':
      return { ...state, isSubmitting: true };
    case 'SUBMIT_END':
      return { ...state, isSubmitting: false };
    case 'RESET':
      return formInitialState;
    default:
      return state;
  }
}

function useForm(onSubmit) {
  const [state, dispatch] = React.useReducer(formReducer, formInitialState);

  const handleChange = React.useCallback((e) => {
    const { name, value } = e.target;
    dispatch({ type: 'SET_FIELD', field: name, value });
  }, []);

  const handleSubmit = React.useCallback(async (e) => {
    e.preventDefault();
    dispatch({ type: 'SUBMIT_START' });
    try {
      await onSubmit(state.values);
      dispatch({ type: 'RESET' });
    } catch (error) {
      Object.entries(error.validationErrors || {}).forEach(([field, msg]) => {
        dispatch({ type: 'SET_ERROR', field, error: msg });
      });
    } finally {
      dispatch({ type: 'SUBMIT_END' });
    }
  }, [state.values, onSubmit]);

  return { ...state, handleChange, handleSubmit };
}
Hook Cas d'usage Coût de performance Quand l'utiliser
useMemo Calculs lourds (O(n²) +) Overhead: création closure Toujours si > 1ms
useCallback Props passées à enfants mémoïsés Minimal Obligatoire avec memo()
useReducer État complexe avec transitions Negligible État > 3 propriétés
useRef Références DOM, valeurs privées Aucun Accès direct DOM requis
useContext État partagé global Peut causer rendus inutiles Données stables

Astuce d'expert
Profilez toujours avant d'optimiser. Utilisez React DevTools Profiler pour mesurer le coût réel. useMemo a un coût : création de la fonction de comparaison et de la closure. Si votre calcul prend moins de 1ms, le overhead est plus important que le gain. Préférez l'immuabilité structurelle pour les dépendances simples.

Attention critique
useCallback avec des dépendances incomplètes crée des "closures stales" : la fonction capture les anciennes valeurs. Inversement, ajouter tout dans les dépendances rend useCallback inutile. Les règles ESLint doivent être strictes : eslint-plugin-react-hooks. Un reducer mal conçu peut devenir impossible à tester ; validez que chaque action est pure et déterministe.


3. Optimisation du Rendu et Stratégies de Code-Splitting Avancées

Définition
L'optimisation du rendu consiste à minimiser les rendus inutiles et à réduire le temps de chaque rendu. Le code-splitting décompose l'application en chunks chargés à la demande, réduisant le bundle initial et améliorant les performances du réseau.

Explication approfondie
React.memo est un HOC qui mémoïse un composant : il ne se re-rend que si ses props changent. Cependant, React.memo fait une comparaison superficielle (shallow comparison). Pour les objets imbriqués, cela peut être insuffisant. useMemo peut être combiné avec React.memo pour stabiliser les props complexes.

Le code-splitting divise l'application en chunks et les charge dynamiquement avec React.lazy et Suspense. Le lazy loading avec React.lazy enveloppe une importation dynamique, permettant au bundler (Webpack, Vite) de créer un chunk séparé. Suspense capture le state "en attente" du composant lazy et affiche un fallback.

Boundary-based splitting divise au niveau des routes avec react-router et au niveau des features. Une granularité incorrecte (trop grossier) annule les bénéfices ; trop fin crée trop de requêtes réseau. Le sweet spot dépend de votre architecture.

L'hydratation côté serveur (SSR) exige un code-splitting particulier : le HTML initial doit être rendu côté serveur, puis "hydraté" côté client. Les composants lazy doivent être gérés avec soin pour éviter les mismatches.

// ❌ Mauvaise utilisation de React.memo
function UserCard({ user, onUpdate }) {
  return (
    <div>
      <h3>{user.name}</h3>
      <button onClick={() => onUpdate(user.id)}>Update</button>
    </div>
  );
}

const MemoizedUserCard = React.memo(UserCard);

// Le problème : onUpdate est créée à chaque render du parent
function UserList({ users }) {
  return users.map(user => (
    <MemoizedUserCard 
      key={user.id}
      user={user}
      onUpdate={(id) => console.log('Update', id)}
    />
  ));
}

// ✅ Solution : useCallback stabilise onUpdate
function UserList({ users }) {
  const handleUpdate = React.useCallback((id) => {
    console.log('Update', id);
  }, []);

  return users.map(user => (
    <MemoizedUserCard 
      key={user.id}
      user={user}
      onUpdate={handleUpdate}
    />
  ));
}

// Code-splitting avec React.lazy et Suspense
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Analytics = React.lazy(() => import('./pages/Analytics'));
const Settings = React.lazy(() => import('./pages/Settings'));

function AppRouter() {
  return (
    <Router>
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/analytics" element={<Analytics />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </Router>
  );
}

// Pattern avancé : Prefetching with link anticipation
function usePrefetch(importFunc, delay = 2000) {
  React.useEffect(() => {
    const timer = setTimeout(() => {
      importFunc();
    }, delay);
    return () => clearTimeout(timer);
  }, [importFunc, delay]);
}

function PrefetchedLink({ to, importFunc, children }) {
  const [isPrefetched, setIsPrefetched] = React.useState(false);

  const handleMouseEnter = React.useCallback(() => {
    if (!isPrefetched) {
      importFunc().then(() => setIsPrefetched(true));
    }
  }, [importFunc, isPrefetched]);

  return (
    <Link to={to} onMouseEnter={handleMouseEnter}>
      {children}
    </Link>
  );
}

// Exemple d'utilisation
<PrefetchedLink 
  to="/analytics"
  importFunc={() => import('./pages/Analytics')}
>
  Go to Analytics
</PrefetchedLink>

// Advanced: Dynamic imports avec Web Vitals optimization
function useLazyComponent(importFunc, options = {}) {
  const [Component, setComponent] = React.useState(null);
  const [error, setError] = React.useState(null);
  const [isLoading, setIsLoading] = React.useState(true);

  React.useEffect(() => {
    let cancelled = false;

    const timeoutId = setTimeout(() => {
      importFunc()
        .then(module => {
          if (!cancelled) {
            setComponent(() => module.default);
            setIsLoading(false);
          }
        })
        .catch(err => {
          if (!cancelled) {
            setError(err);
            setIsLoading(false);
          }
        });
    }, options.delay || 0);

    return () => {
      cancelled = true;
      clearTimeout(timeoutId);
    };
  }, [importFunc, options.delay]);

  return { Component, error, isLoading };
}

// Suspense avec Error Boundary pour gestion d'erreur
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    console.error('Error caught:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div>
          <h2>Erreur lors du chargement</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => window.location.reload()}>
            Recharger la page
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

function App() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<Skeleton />}>
        <Routes>
          <Route path="/dashboard" element={<DashboardPage />} />
        </Routes>
      </Suspense>
    </ErrorBoundary>
  );
}

// Optimisation des images avec dynamic imports
function ImageWithFallback({ src, alt, placeholder }) {
  const [imageSrc, setImageSrc] = React.useState(placeholder);
  const [imageRef, setImageRef] = React.useState(null);

  React.useEffect(() => {
    const img = new Image();
    img.onload = () => setImageSrc(src);
    img.onerror = () => setImageSrc(placeholder);
    img.src = src;
  }, [src, placeholder]);

  return <img src={imageSrc} alt={alt} ref={setImageRef} />;
}
Stratégie Impact sur LCP Impact sur FCP Complexité Cas d'usage
React.memo Minimal Minimal Basse Composants purs fréquemment re-rendus
Code-splitting routes Élevé Moyen Moyenne Applications multi-pages
Code-splitting features Moyen Moyen Haute Dépendances volumineuses optionnelles
Lazy images Minimal Élevé Basse Images au-dessous du fold
Prefetching Très négatif si mal fait Positif Moyenne Navigation prévisible

Astuce d'expert
Utilisez Webpack Bundle Analyzer ou source-map-explorer pour identifier les chunks suspects. Les problèmes de performance viennent souvent de dépendances transitives volumineuses (lodash, date-fns non tree-shaken). Configurez le prefetching intelligemment : préchargez les routes probables basé sur l'analytics utilisateur, pas toutes les routes.

Attention critique
React.memo ne doit pas être utilisé partout : c'est prématuriste et rend le code moins maintenable. Les dépendances dans useCallback doivent être minimales et stables. Le code-splitting trop fin crée le "waterfall problem" : la page télécharge un chunk, qui charge un autre chunk, puis un autre. Mesure les Core Web Vitals en situation réelle (Lighthouse, CrUX) avant et après.


4. Gestion Avancée de l'État : Context, Réduction et Patterns d'Optimisation

Définition
La gestion d'état avancée implique des patterns pour partager l'état sans prop drilling, éviter les rendus cascades en Context, et implémenter des patterns comme Flux ou Redux-like architectures directement en React avec des Hooks.

Explication approfondie
Context API semble simple : createContext, Provider, useContext. Mais il a une limitation critique : tout changement du value du Provider cause un rendu de tous les consommateurs, même si seule une partie de l'état les intéresse. C'est le "context explosion problem".

La solution classique est de diviser le context en contextes plus petits, chacun responsable d'une partie de l'état. Le pattern "context composition" combine plusieurs contextes. Une autre approche utilise useReducer au niveau racine et propage le dispatch plutôt que l'état complet.

Pour les applications complexes, on peut implémenter un pattern Flux-like : une source unique de vérité, des actions dispatched, un reducer centralisé. Cela se fait élégamment avec useReducer + Context, sans nécessité de Redux.

Le pattern "selector pattern" dans le contexte signifie créer un Hook custom qui mémoïse le sous-ensemble d'état utilisé par un composant. Si ce sous-ensemble ne change pas, le composant ne se re-rend pas, même si d'autres parties du contexte changent.

// ❌ Problème : Context Provider cause rendus en cascade
const ThemeContext = React.createContext();

function App() {
  const [theme, setTheme] = React.useState('light');
  const [language, setLanguage] = React.useState('fr');
  const [notifications, setNotifications] = React.useState([]);

  return (
    <ThemeContext.Provider value={{ theme, language, notifications, setTheme, setLanguage, setNotifications }}>
      <MainApp />
    </ThemeContext.Provider>
  );
}

// Chaque consommateur se re-rend même s'il utilise juste theme
function Header() {
  const { theme, setTheme } = React.useContext(ThemeContext);
  console.log('Header re-rendered');
  return <header>{theme}</header>;
}

// ✅ Solution 1 : Diviser en contextes
const ThemeContext = React.createContext();
const LanguageContext = React.createContext();
const NotificationsContext = React.createContext();

function App() {
  const [theme, setTheme] = React.useState('light');
  const [language, setLanguage] = React.useState('fr');
  const [notifications, setNotifications] = React.useState([]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <LanguageContext.Provider value={{ language, setLanguage }}>
        <NotificationsContext.Provider value={{ notifications, setNotifications }}>
          <MainApp />
        </NotificationsContext.Provider>
      </LanguageContext.Provider>
    </ThemeContext.Provider>
  );
}

// ✅ Solution 2 : Selector pattern avec useMemo
const AppStateContext = React.createContext();
const AppDispatchContext = React.createContext();

const initialState = {
  theme: 'light',
  language: 'fr',
  notifications: [],
  user: null,
};

function appReducer(state, action) {
  switch (action.type) {
    case 'SET_THEME':
      return { ...state, theme: action.payload };
    case 'SET_LANGUAGE':
      return { ...state, language: action.payload };
    case 'ADD_NOTIFICATION':
      return { ...state, notifications: [...state.notifications, action.payload] };
    case 'SET_USER':
      return { ...state, user: action.payload };
    default:
      return state;
  }
}

function App() {
  const [state, dispatch] = React.useReducer(appReducer, initialState);

  const themeValue = React.useMemo(() => ({
    theme: state.theme,
    setTheme: (theme) => dispatch({ type: 'SET_THEME', payload: theme })
  }), [state.theme]);

  const notificationsValue = React.useMemo(() => ({
    notifications: state.notifications,
    addNotification: (notif) => dispatch({ type: 'ADD_NOTIFICATION', payload: notif })
  }), [state.notifications]);

  return (
    <AppStateContext.Provider value={state}>
      <AppDispatchContext.Provider value={dispatch}>
        <ThemeContext.Provider value={themeValue}>
          <NotificationsContext.Provider value={notificationsValue}>
            <MainApp />
          </NotificationsContext.Provider>
        </ThemeContext.Provider>
      </AppDispatchContext.Provider>
    </AppStateContext.Provider>
  );
}

// Custom Hook pour sélectionner une partie de l'état
function useAppState(selector) {
  const state = React.useContext(AppStateContext);
  return React.useMemo(() => selector(state), [state, selector]);
}

function useDispatch() {
  return React.useContext(AppDispatchContext);
}

// Utilisation avec memoization
const MemoizedHeader = React.memo(function Header() {
  // Sélecteur : ne se re-rend que si theme change
  const theme = useAppState(state => state.theme);
  const dispatch = useDispatch();

  console.log('Header re-rendered');

  return (
    <header style={{ background: theme === 'light' ? '#fff' : '#222' }}>
      <button onClick={() => dispatch({ type: 'SET_THEME', payload: theme === 'light' ? 'dark' : 'light' })}>
        Toggle Theme
      </button>
    </header>
  );
});

// ✅ Solution 3 : Custom Hook composition pour état complexe
function useAppSettings() {
  const state = React.useContext(AppStateContext);
  const dispatch = React.useContext(AppDispatchContext);

  return React.useMemo(() => ({
    theme: state.theme,
    language: state.language,
    setTheme: (theme) => dispatch({ type: 'SET_THEME', payload: theme }),
    setLanguage: (lang) => dispatch({ type: 'SET_LANGUAGE', payload: lang }),
    isDark: state.theme === 'dark',
  }), [state.theme, state.language, dispatch]);
}

// Pattern avancé : useReducer avec middleware (pour logging, analytics)
function useReducerWithMiddleware(reducer, initialState, middleware) {
  const [state, dispatch] = React.useReducer(reducer, initialState);

  const enhancedDispatch = React.useCallback((action) => {
    middleware(action, state);
    dispatch(action);
  }, [state, middleware]);

  return [state, enhancedDispatch];
}

// Utilisation
const loggingMiddleware = (action, state) => {
  console.log('Action:', action);
  console.log('Previous state:', state);
};

const [state, dispatch] = useReducerWithMiddleware(
  appReducer,
  initialState,
  loggingMiddleware
);

// Pattern : Async thunks-like avec useReducer
function useAsyncReducer(reducer, initialState) {
  const [state, baseDispatch] = React.useReducer(reducer, initialState);

  const dispatch = React.useCallback(async (action) => {
    if (typeof action === 'function') {
      return action(dispatch);
    }
    baseDispatch(action);
  }, []);

  return [state, dispatch];
}

const enhancedAppReducer = (state, action) => {
  if (action.type === 'FETCH_USER') {
    return { ...state, userLoading: true };
  }
  if (action.type === 'FETCH_USER_SUCCESS') {
    return { ...state, user: action.payload, userLoading: false };
  }
  return state;
};

// Async thunk-like function
function fetchUser(userId) {
  return async (dispatch) => {
    dispatch({ type: 'FETCH_USER' });
    try {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      dispatch({ type: 'FETCH_USER_SUCCESS', payload: data });
    } catch (error
Accédez à des centaines d'examens QCM — Découvrir les offres Premium