Cours JavaScript et React · Module 4: Context API et useReducer

Patterns de gestion d'état

7 min de lecture
Dans cette leçon7

À la Station Jupiter, tu gères de nombreux systèmes simultanément - navigation, carburant, équipage, communications. Chaque système a besoin de données et doit les partager avec d'autres modules. Dans React, la gestion de l'état est l'un des défis architecturaux les plus importants. Choisir le bon pattern détermine si ton application sera facile à maintenir et évolutive, ou si elle deviendra un labyrinthe chaotique de dépendances.

Le problème : le prop drilling

Avant que tu n'apprennes le Context API, la seule façon de transmettre des données dans l'arbre des composants était le prop drilling - passer manuellement des props à travers chaque niveau. C'est comme envoyer des messages à travers une chaîne de membres d'équipage, où chacun doit répéter le message, même s'il n'en a pas besoin lui-même.

1// Prop drilling - 4 niveaux de transmission !
2function SpaceStation({ missionData }) {
3  return <Deck missionData={missionData} />;
4}
5
6function Deck({ missionData }) {
7  // Deck n'utilise pas missionData - il le transmet simplement
8  return <Room missionData={missionData} />;
9}
10
11function Room({ missionData }) {
12  // Room ne l'utilise pas non plus - il le transmet
13  return <Terminal missionData={missionData} />;
14}
15
16function Terminal({ missionData }) {
17  // Ce n'est qu'ici que les données sont réellement nécessaires !
18  return <p>Mission : {missionData.name}</p>;
19}

À 4 niveaux, c'est encore gérable, mais à 8-10 niveaux (ce qui est normal dans les grandes applications), le code devient illisible, et chaque changement dans la structure des données nécessite de modifier de nombreux composants intermédiaires.

State colocation - garder l'état près de l'endroit où il est utilisé

Avant de recourir à des solutions globales, envisage la state colocation - le principe qui consiste à garder l'état aussi proche que possible des composants qui en ont besoin. Toutes les données n'ont pas besoin d'être globales. C'est comme sur un vaisseau spatial : la température du cockpit n'a pas besoin d'être connue dans la salle des machines.

1// MAUVAIS : État global pour des données locales
2function App() {
3  const [searchQuery, setSearchQuery] = useState('');
4  const [isModalOpen, setIsModalOpen] = useState(false);
5  const [formData, setFormData] = useState({});
6  // ... 20 autres états
7
8  return (
9    <GlobalContext.Provider value={/* tout */}>
10      <Dashboard />
11    </GlobalContext.Provider>
12  );
13}
14
15// BON : État proche de l'endroit où il est utilisé
16function App() {
17  // Uniquement les données vraiment globales
18  const [user, setUser] = useState(null);
19  const [theme, setTheme] = useState('dark');
20
21  return (
22    <AppContext.Provider value={{ user, theme }}>
23      <Dashboard />
24    </AppContext.Provider>
25  );
26}
27
28function SearchPanel() {
29  // État local - seul ce composant en a besoin
30  const [searchQuery, setSearchQuery] = useState('');
31  return <input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />;
32}

Règle empirique : si l'état n'est utilisé que par un seul composant ou ses enfants directs - garde-le local. Remonte-le (lifting state up) ou place-le dans le contexte uniquement lorsque des branches éloignées de l'arbre des composants en ont besoin.

Context API - la solution intégrée

Le Context API résout le prop drilling sans bibliothèques supplémentaires. Tu l'as découvert plus tôt dans ce module. Ses principaux atouts et limites :

Atouts :

  • Intégré à React - zéro dépendance supplémentaire
  • API simple : createContext, Provider, useContext
  • Excellent pour les données qui changent rarement (thème, auth, locale)

Limites :

  • Chaque changement de valeur re-rend TOUS les consommateurs
  • Pas de mise en cache intégrée pour les données d'API
  • Nécessite une combinaison avec useReducer pour un état complexe
  • Optimisation des performances difficile dans les grandes applications
1// Context API - bon choix pour la configuration du vaisseau
2const ShipConfigContext = createContext();
3
4function ShipConfigProvider({ children }) {
5  const [config, setConfig] = useState({
6    theme: 'dark-nebula',
7    language: 'en',
8    soundEnabled: true,
9  });
10
11  return (
12    <ShipConfigContext.Provider value={{ config, setConfig }}>
13      {children}
14    </ShipConfigContext.Provider>
15  );
16}

Redux - centre de commandement pour les grandes flottes

Redux est un pattern de gestion d'état éprouvé, inspiré de l'architecture Flux. Il fonctionne comme le centre de commandement central d'une flotte - un seul store contient tout l'état de l'application, et les changements se produisent exclusivement via le dispatch d'actions.

1// Redux Toolkit - la manière moderne d'utiliser Redux
2// import { createSlice, configureStore } from '@reduxjs/toolkit';
3
4// Slice = un morceau d'état + reducers au même endroit
5const missionSlice = {
6  name: 'mission',
7  initialState: {
8    crew: [],
9    fuel: 100,
10    destination: null,
11    status: 'docked',
12  },
13  reducers: {
14    addCrewMember: (state, action) => {
15      state.crew.push(action.payload);
16    },
17    setDestination: (state, action) => {
18      state.destination = action.payload;
19    },
20    burnFuel: (state, action) => {
21      state.fuel = Math.max(0, state.fuel - action.payload);
22    },
23    launch: (state) => {
24      state.status = 'in-flight';
25    },
26  },
27};
28
29// Utilisation dans un composant :
30function MissionControl() {
31  // const dispatch = useDispatch();
32  // const { crew, fuel, status } = useSelector(state => state.mission);
33
34  // dispatch(missionSlice.actions.launch());
35  // dispatch(missionSlice.actions.burnFuel(10));
36}

Quand utiliser Redux ?

  • Applications avec de nombreux développeurs (règles claires, flux de données prévisible)
  • État complexe avec de nombreuses actions liées
  • Tu as besoin des DevTools avec le débogage time-travel
  • Middleware pour gérer les effets de bord (thunks, sagas)

Quand NE PAS utiliser Redux ?

  • Applications simples avec peu d'états
  • Petite équipe ou développeur en solo
  • Principalement des données d'API (React Query est meilleur)

Zustand - le propulseur de manœuvre léger

Zustand est une bibliothèque minimaliste de gestion d'état. Si Redux est le moteur principal, Zustand est le propulseur de manœuvre agile - il fait la même chose, mais avec moins de surcharge. Pas de providers, pas de code répétitif, pas de problèmes de re-rendus du Context API.

1// Zustand - store simple sans providers
2// import { create } from 'zustand';
3
4// Création d'un store - c'est tout ce dont tu as besoin !
5// const useShipStore = create((set) => ({
6//   fuel: 100,
7//   position: { x: 0, y: 0 },
8//   crew: 5,
9//
10//   burnFuel: (amount) => set((state) => ({
11//     fuel: Math.max(0, state.fuel - amount)
12//   })),
13//
14//   moveTo: (x, y) => set({ position: { x, y } }),
15//
16//   addCrew: () => set((state) => ({
17//     crew: state.crew + 1
18//   })),
19// }));
20
21// Utilisation dans un composant - pas besoin de Provider !
22function FuelGauge() {
23  // Le composant se re-rend UNIQUEMENT quand le carburant change
24  // const fuel = useShipStore((state) => state.fuel);
25  // return <div>Carburant : {fuel}%</div>;
26}
27
28function NavigationPanel() {
29  // Lit UNIQUEMENT la position - un changement de carburant ne provoque pas de re-rendu
30  // const position = useShipStore((state) => state.position);
31  // const moveTo = useShipStore((state) => state.moveTo);
32  // return <button onClick={() => moveTo(10, 20)}>Décoller !</button>;
33}

Zustand optimise automatiquement les re-rendus grâce aux selectors. Chaque composant ne s'abonne qu'aux données qu'il utilise réellement. Un changement de carburant ne re-rend pas le panneau de navigation - et vice versa.

Comparaison des patterns - carte de la galaxie

CaractéristiqueContext APIRedux ToolkitZustand
Taille0 KB (intégré)~11 KB~1.5 KB
Code répétitifMoyenImportant (mais RTK aide)Minimal
Re-rendusTous les consommateursOptimisés (selectors)Optimisés (selectors)
DevToolsAucun dédiéÉtendus (time-travel)Extension Redux DevTools
Courbe d'apprentissageFacileRaideFacile
Idéal pourThème, auth, localeGrandes apps, grandes équipesLa plupart des applications

Le principe de sélection de l'outil

À la Station Jupiter, chaque système a sa raison d'être. Il en va de même pour la gestion d'état :

  1. État local (useState) - données nécessaires uniquement à un seul composant ou ses enfants directs
  2. État remonté - données partagées par 2-3 composants proches, déplace-les dans leur parent commun
  3. Context API - données globales qui changent rarement : thème, auth, locale, langue
  4. Zustand - état global dans les applications petites/moyennes quand tu as besoin de performance et de simplicité
  5. Redux Toolkit - grandes applications avec de nombreux développeurs, logique métier complexe, besoin de DevTools

N'oublie pas : il n'existe pas de solution unique et idéale. Les meilleurs ingénieurs spatiaux adaptent l'outil au problème, et non le problème à l'outil.

Teste la comparaison des patterns dans l'éditeur ci-dessous :

Code de cette leçon : App.jsx
1import React, { createContext, useContext, useState, useReducer } from 'react';
2
3// === PATTERNS DE GESTION D'ÉTAT ===
4
5// Démo : Comparaison des patterns de gestion d'état
6// 1. Context API - intégré à React
7const ThemeContext = React.createContext();
8
9function ThemeProvider({ children }) {
10  const [theme, setTheme] = useState('dark-nebula');
11  const toggleTheme = () => setTheme(t => t === 'dark-nebula' ? 'light-star' : 'dark-nebula');
12  return (
13    <ThemeContext.Provider value={{ theme, toggleTheme }}>
14      {children}
15    </ThemeContext.Provider>
16  );
17}
18
19function useTheme() {
20  const ctx = useContext(ThemeContext);
21  if (!ctx) throw new Error('useTheme must be inside ThemeProvider');
22  return ctx;
23}
24
25// 2. useReducer - pattern de type Redux intégré à React
26const missionReducer = (state, action) => {
27  switch (action.type) {
28    case 'ADD_CREW':
29      return { ...state, crew: [...state.crew, action.payload] };
30    case 'SET_DESTINATION':
31      return { ...state, destination: action.payload };
32    case 'BURN_FUEL':
33      return { ...state, fuel: Math.max(0, state.fuel - action.payload) };
34    case 'LAUNCH':
35      return { ...state, status: 'in-flight' };
36    case 'DOCK':
37      return { ...state, status: 'docked', fuel: 100 };
38    default:
39      return state;
40  }
41};
42
43const initialMission = {
44  crew: ['Capitaine Ra', 'Navigatrice Nova'],
45  fuel: 100,
46  destination: null,
47  status: 'docked',
48};
49
50// === PANNEAUX DE DÉMONSTRATION ===
51
52function ContextDemo() {
53  const { theme, toggleTheme } = useTheme();
54  const isDark = theme === 'dark-nebula';
55  return (
56    <div style={{ ...styles.panel, background: isDark ? '#1a2035' : '#e8eaf6' }}>
57      <h3 style={{ color: '#4fc3f7' }}>Context API</h3>
58      <p style={{ color: isDark ? '#ccc' : '#333', fontSize: 13 }}>
59        Thème : <strong>{theme}</strong>
60      </p>
61      <p style={{ color: isDark ? '#888' : '#666', fontSize: 11 }}>
62        Idéal pour : thème, auth, locale
63      </p>
64      <button style={styles.btn} onClick={toggleTheme}>Changer de thème</button>
65    </div>
66  );
67}
68
69function ReducerDemo() {
70  const [state, dispatch] = useReducer(missionReducer, initialMission);
71  const destinations = ['Mars', 'Europa', 'Titan', 'Kepler-442b'];
72
73  return (
74    <div style={styles.panel}>
75      <h3 style={{ color: '#ffd700' }}>useReducer (type Redux)</h3>
76      <div style={styles.stats}>
77        <div>Statut : <span style={{ color: state.status === 'in-flight' ? '#69f0ae' : '#ffd700' }}>{state.status}</span></div>
78        <div>Carburant : <span style={{ color: state.fuel < 30 ? '#ff5252' : '#69f0ae' }}>{state.fuel}%</span></div>
79        <div>Destination : <span style={{ color: '#4fc3f7' }}>{state.destination || 'aucune'}</span></div>
80        <div>Équipage : {state.crew.join(', ')}</div>
81      </div>
82      <div style={styles.btnRow}>
83        {state.status === 'docked' ? (
84          <>
85            <button style={styles.btn} onClick={() => {
86              const dest = destinations[Math.floor(Math.random() * destinations.length)];
87              dispatch({ type: 'SET_DESTINATION', payload: dest });
88            }}>Choisir une destination</button>
89            {state.destination && (
90              <button style={{ ...styles.btn, background: 'rgba(105,240,174,0.2)', borderColor: '#69f0ae' }}
91                onClick={() => dispatch({ type: 'LAUNCH' })}>
92                Décoller !
93              </button>
94            )}
95          </>
96        ) : (
97          <>
98            <button style={styles.btn} onClick={() => dispatch({ type: 'BURN_FUEL', payload: 15 })}>
99              Moteurs (-15)
100            </button>
101            <button style={{ ...styles.btn, background: 'rgba(255,215,0,0.2)' }}
102              onClick={() => dispatch({ type: 'DOCK' })}>
103              Amarrer
104            </button>
105          </>
106        )}
107      </div>
108    </div>
109  );
110}
111
112function ColocationDemo() {
113  // État local - n'a pas besoin de Context ni de Redux
114  const [localSearch, setLocalSearch] = useState('');
115  const [results, setResults] = useState([]);
116
117  const planets = ['Mars', 'Jupiter', 'Saturn', 'Neptune', 'Venus', 'Mercury', 'Uranus', 'Pluto'];
118
119  const handleSearch = (query) => {
120    setLocalSearch(query);
121    if (query.length > 0) {
122      setResults(planets.filter(p => p.toLowerCase().includes(query.toLowerCase())));
123    } else {
124      setResults([]);
125    }
126  };
127
128  return (
129    <div style={styles.panel}>
130      <h3 style={{ color: '#ce93d8' }}>State Colocation</h3>
131      <p style={{ color: '#888', fontSize: 11, marginBottom: 8 }}>
132        État local - sans Context/Redux
133      </p>
134      <input
135        style={styles.input}
136        placeholder="Rechercher une planète..."
137        value={localSearch}
138        onChange={e => handleSearch(e.target.value)}
139      />
140      {results.length > 0 && (
141        <div style={styles.resultList}>
142          {results.map(r => (
143            <div key={r} style={styles.resultItem}>{r}</div>
144          ))}
145        </div>
146      )}
147    </div>
148  );
149}
150
151function ComparisonTable() {
152  const data = [
153    { pattern: 'useState', size: '0 KB', reRenders: 'Local', bestFor: 'État d\'un composant' },
154    { pattern: 'Context API', size: '0 KB', reRenders: 'Tous les consommateurs', bestFor: 'Thème, auth, locale' },
155    { pattern: 'useReducer', size: '0 KB', reRenders: 'Local', bestFor: 'Logique d\'état complexe' },
156    { pattern: 'Redux (RTK)', size: '~11 KB', reRenders: 'Sélectif', bestFor: 'Grandes apps, équipes' },
157    { pattern: 'Zustand', size: '~1.5 KB', reRenders: 'Sélectif', bestFor: 'La plupart des applications' },
158  ];
159
160  return (
161    <div style={{ ...styles.panel, gridColumn: '1 / -1' }}>
162      <h3 style={{ color: '#ffd700', textAlign: 'center' }}>Comparaison des patterns</h3>
163      <div style={styles.table}>
164        <div style={styles.tableHeader}>
165          <span>Pattern</span><span>Taille</span><span>Re-rendus</span><span>Idéal pour</span>
166        </div>
167        {data.map(row => (
168          <div key={row.pattern} style={styles.tableRow}>
169            <span style={{ color: '#4fc3f7' }}>{row.pattern}</span>
170            <span>{row.size}</span>
171            <span>{row.reRenders}</span>
172            <span style={{ color: '#888' }}>{row.bestFor}</span>
173          </div>
174        ))}
175      </div>
176    </div>
177  );
178}
179
180function App() {
181  return (
182    <ThemeProvider>
183      <div style={styles.container}>
184        <h1 style={styles.title}>Patterns de gestion d'état</h1>
185        <p style={styles.subtitle}>
186          Comparaison de Context API, useReducer et de la state colocation
187        </p>
188        <div style={styles.grid}>
189          <ContextDemo />
190          <ReducerDemo />
191          <ColocationDemo />
192          <ComparisonTable />
193        </div>
194      </div>
195    </ThemeProvider>
196  );
197}
198
199const styles = {
200  container: {
201    fontFamily: 'Arial, sans-serif',
202    background: 'linear-gradient(135deg, #0a0e1a 0%, #1a1a3e 100%)',
203    minHeight: '100vh',
204    padding: 24,
205    color: 'white',
206  },
207  title: { textAlign: 'center', color: '#ffd700', marginBottom: 4 },
208  subtitle: { textAlign: 'center', color: '#888', fontSize: 13, marginBottom: 24 },
209  grid: {
210    display: 'grid',
211    gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
212    gap: 16,
213    maxWidth: 900,
214    margin: '0 auto',
215  },
216  panel: {
217    background: '#1a2035',
218    borderRadius: 10,
219    padding: 16,
220    border: '1px solid rgba(79,195,247,0.2)',
221  },
222  stats: {
223    fontSize: 13,
224    lineHeight: 1.8,
225    marginBottom: 12,
226    padding: '8px 12px',
227    background: 'rgba(0,0,0,0.3)',
228    borderRadius: 8,
229  },
230  btnRow: { display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' },
231  btn: {
232    padding: '6px 16px',
233    background: 'rgba(255,255,255,0.08)',
234    color: 'white',
235    border: '1px solid rgba(255,255,255,0.15)',
236    borderRadius: 6,
237    cursor: 'pointer',
238    fontSize: 13,
239  },
240  input: {
241    width: '100%',
242    padding: '8px 12px',
243    background: 'rgba(0,0,0,0.3)',
244    color: 'white',
245    border: '1px solid rgba(79,195,247,0.3)',
246    borderRadius: 6,
247    fontSize: 13,
248    outline: 'none',
249    boxSizing: 'border-box',
250  },
251  resultList: {
252    marginTop: 8,
253    background: 'rgba(0,0,0,0.2)',
254    borderRadius: 6,
255    overflow: 'hidden',
256  },
257  resultItem: {
258    padding: '6px 12px',
259    borderBottom: '1px solid rgba(255,255,255,0.05)',
260    fontSize: 13,
261    color: '#ce93d8',
262  },
263  table: { fontSize: 12 },
264  tableHeader: {
265    display: 'grid',
266    gridTemplateColumns: '1fr 0.7fr 1fr 1.3fr',
267    gap: 8,
268    padding: '8px 0',
269    borderBottom: '1px solid rgba(255,215,0,0.3)',
270    fontWeight: 'bold',
271    color: '#ffd700',
272  },
273  tableRow: {
274    display: 'grid',
275    gridTemplateColumns: '1fr 0.7fr 1fr 1.3fr',
276    gap: 8,
277    padding: '6px 0',
278    borderBottom: '1px solid rgba(255,255,255,0.05)',
279    color: '#ccc',
280  },
281};
282
283export default App;

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quand faut-il utiliser le Context API plutôt que de passer des props (prop drilling) ?

  2. 2. Que se passe-t-il lorsqu'un seul grand contexte stocke la position du vaisseau (mise à jour 60x/s) et le niveau de carburant, et qu'un composant FuelGauge ne lit que le carburant ?

Voici 2 des 5 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Crée trois contextes séparés (FuelContext, PositionContext, CrewContext) avec leurs propres providers. Compose-les ensemble dans ShipProviders. Affiche les données de chaque contexte dans des composants distincts.

  • Clique dans l'ordre

    Ordonne les étapes pour créer un custom hook d'accès sûr au contexte :

  • Tri vertical

    Ordonne les étapes de construction d'un système de contextes séparés, de l'extérieur vers l'intérieur :

  • Éditeur de code

    Implémente un custom hook useFuelContext avec validation de la disponibilité du contexte. Crée un FuelProvider et un composant FuelPanel qui utilise le hook.

Articles utiles