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

Context et useReducer en pratique

6 min de lecture
Dans cette leçon6

Tu as déjà appris le Context API et useReducer séparément. Il est maintenant temps de combiner ces deux outils en un seul système complet de gestion d'état - tout comme un centre de commandement de mission spatiale relie tous les systèmes de communication en un seul panneau de contrôle unifié.

Pourquoi combiner Context avec useReducer ?

useReducer seul centralise la logique d'état, mais l'état reste local à un seul composant. Context API seul rend les données globalement disponibles, mais gérer des mises à jour complexes via setState devient désordonné. Combiner les deux nous offre le meilleur des deux mondes :

  • Context - rend l'état et dispatch disponibles à chaque composant de l'arbre sans prop drilling
  • useReducer - organise toute la logique de mise à jour dans une fonction unique et testable

Analogie spatiale : Context est le réseau de communication du vaisseau qui atteint chaque cabine. useReducer est l'ordinateur central qui traite toutes les commandes. Ensemble, ils forment le Centre de contrôle de mission - chaque membre d'équipage peut envoyer une commande et recevoir le statut actuel, sans avoir à courir dans les couloirs avec des notes.

Pattern : contexte avec state et dispatch

Le pattern clé consiste à placer à la fois state et dispatch dans la valeur du contexte :

1import { createContext, useContext, useReducer } from 'react';
2
3// Crée le contexte
4const MissionContext = createContext();
5
6// Reducer avec la logique d'état
7function missionReducer(state, action) {
8  switch (action.type) {
9    case 'SET_STATUS':
10      return { ...state, status: action.payload };
11    case 'ADD_CREW':
12      return { ...state, crew: [...state.crew, action.payload] };
13    case 'UPDATE_FUEL':
14      return { ...state, fuel: Math.max(0, Math.min(100, action.payload)) };
15    default:
16      return state;
17  }
18}
19
20// Provider - combine Context avec useReducer
21function MissionProvider({ children }) {
22  const initialState = {
23    status: 'docked',
24    crew: ['Capitaine Nova'],
25    fuel: 100
26  };
27
28  const [state, dispatch] = useReducer(missionReducer, initialState);
29
30  return (
31    <MissionContext.Provider value={{ state, dispatch }}>
32      {children}
33    </MissionContext.Provider>
34  );
35}

Custom hook d'accès au contexte

Au lieu d'appeler useContext(MissionContext) dans chaque composant, nous créons un hook dédié. C'est une convention standard qui :

  • Protège contre l'utilisation du contexte en dehors du provider (lance une erreur lisible)
  • Simplifie les imports dans les composants
  • Facilite les futurs refactorings
1function useMission() {
2  const context = useContext(MissionContext);
3  if (!context) {
4    throw new Error('useMission must be used within a MissionProvider');
5  }
6  return context;
7}

Utiliser dispatch depuis les composants enfants

Chaque composant à l'intérieur du provider peut désormais envoyer directement des actions - sans transmettre de fonctions via les props :

1function StatusPanel() {
2  const { state, dispatch } = useMission();
3
4  return (
5    <div className="status-panel">
6      <h3>Statut de la mission : {state.status}</h3>
7      <button onClick={() => dispatch({ type: 'SET_STATUS', payload: 'launching' })}>
8        Commencer le lancement
9      </button>
10      <button onClick={() => dispatch({ type: 'SET_STATUS', payload: 'orbiting' })}>
11        Entrer en orbite
12      </button>
13      <button onClick={() => dispatch({ type: 'SET_STATUS', payload: 'docked' })}>
14        Amarrer
15      </button>
16    </div>
17  );
18}
19
20function CrewPanel() {
21  const { state, dispatch } = useMission();
22  const [newMember, setNewMember] = React.useState('');
23
24  const addMember = () => {
25    if (newMember.trim()) {
26      dispatch({ type: 'ADD_CREW', payload: newMember.trim() });
27      setNewMember('');
28    }
29  };
30
31  return (
32    <div className="crew-panel">
33      <h3>Équipage ({state.crew.length}) :</h3>
34      <ul>
35        {state.crew.map((member, i) => (
36          <li key={i}>{member}</li>
37        ))}
38      </ul>
39      <input
40        value={newMember}
41        onChange={(e) => setNewMember(e.target.value)}
42        placeholder="Nom du nouveau membre d'équipage"
43      />
44      <button onClick={addMember}>Ajouter à l'équipage</button>
45    </div>
46  );
47}

Le pattern des reducers multiples

À mesure que l'application grandit, un seul gros reducer devient difficile à maintenir. Nous pouvons découper la logique en reducers plus petits par domaine et les combiner dans un reducer racine :

1// Reducer de navigation
2function navigationReducer(navState, action) {
3  switch (action.type) {
4    case 'SET_DESTINATION':
5      return { ...navState, destination: action.payload, status: 'en route' };
6    case 'ARRIVE':
7      return { ...navState, location: navState.destination, destination: null, status: 'arrived' };
8    default:
9      return navState;
10  }
11}
12
13// Reducer des systèmes de bord
14function systemsReducer(systemsState, action) {
15  switch (action.type) {
16    case 'TOGGLE_SHIELDS':
17      return { ...systemsState, shields: !systemsState.shields };
18    case 'SET_ENGINE_POWER':
19      return { ...systemsState, enginePower: action.payload };
20    default:
21      return systemsState;
22  }
23}
24
25// Reducer racine combinant les deux
26function rootReducer(state, action) {
27  return {
28    navigation: navigationReducer(state.navigation, action),
29    systems: systemsReducer(state.systems, action)
30  };
31}
32
33// Provider avec le reducer racine
34function SpaceshipProvider({ children }) {
35  const initialState = {
36    navigation: { location: 'Earth', destination: null, status: 'docked' },
37    systems: { shields: false, enginePower: 0 }
38  };
39
40  const [state, dispatch] = useReducer(rootReducer, initialState);
41
42  return (
43    <SpaceshipContext.Provider value={{ state, dispatch }}>
44      {children}
45    </SpaceshipContext.Provider>
46  );
47}

Le pattern des reducers multiples permet une séparation propre des responsabilités. Chaque mini-reducer gère son propre domaine d'état, mais toutes les actions transitent par un seul dispatch. Ainsi, une action TOGGLE_SHIELDS peut affecter simultanément les systèmes et la navigation, si nécessaire.

Application complète Mission Control

Ci-dessous, tu peux voir tous les éléments rassemblés - de la définition du contexte, en passant par le provider, jusqu'aux composants utilisant useMission :

1import { createContext, useContext, useReducer } from 'react';
2
3const MissionContext = createContext();
4
5function missionReducer(state, action) {
6  switch (action.type) {
7    case 'LAUNCH':
8      return { ...state, status: 'flying', fuel: state.fuel - 20 };
9    case 'DOCK':
10      return { ...state, status: 'docked', speed: 0 };
11    case 'ADD_LOG':
12      return { ...state, log: [...state.log, action.payload] };
13    default:
14      return state;
15  }
16}
17
18function MissionProvider({ children }) {
19  const [state, dispatch] = useReducer(missionReducer, {
20    status: 'docked',
21    fuel: 100,
22    speed: 0,
23    log: []
24  });
25
26  return (
27    <MissionContext.Provider value={{ state, dispatch }}>
28      {children}
29    </MissionContext.Provider>
30  );
31}
32
33function useMission() {
34  return useContext(MissionContext);
35}
36
37// Composant A - panneau de contrôle
38function ControlPanel() {
39  const { state, dispatch } = useMission();
40  return (
41    <div>
42      <button
43        onClick={() => {
44          dispatch({ type: 'LAUNCH' });
45          dispatch({ type: 'ADD_LOG', payload: 'Mission démarrée !' });
46        }}
47        disabled={state.status === 'flying'}
48      >
49        Lancer
50      </button>
51    </div>
52  );
53}
54
55// Composant B - affichage de l'état (branche distincte de l'arbre)
56function MissionLog() {
57  const { state } = useMission();
58  return (
59    <ul>
60      {state.log.map((entry, i) => <li key={i}>{entry}</li>)}
61    </ul>
62  );
63}
64
65// Application
66function App() {
67  return (
68    <MissionProvider>
69      <ControlPanel />
70      <MissionLog />
71    </MissionProvider>
72  );
73}

Remarque que ControlPanel et MissionLog peuvent se trouver à différentes profondeurs dans l'arbre des composants et n'ont pas besoin de se connaître - ils sont uniquement reliés par le MissionProvider partagé.

Code de cette leçon : App.jsx
1import React, { createContext, useContext, useReducer } from 'react';
2
3// --- Reducer pour les missions ---
4const initialState = {
5  missions: [
6    { id: 1, name: 'Exploration de Mars', priority: 'high', completed: false },
7    { id: 2, name: 'Sonde de Jupiter', priority: 'medium', completed: false },
8  ],
9};
10
11function missionReducer(state, action) {
12  switch (action.type) {
13    case 'ADD_MISSION':
14      return {
15        ...state,
16        missions: [
17          ...state.missions,
18          { id: Date.now(), name: action.name, priority: action.priority, completed: false },
19        ],
20      };
21    case 'COMPLETE_MISSION':
22      return {
23        ...state,
24        missions: state.missions.map(m =>
25          m.id === action.id ? { ...m, completed: true } : m
26        ),
27      };
28    case 'SET_PRIORITY':
29      return {
30        ...state,
31        missions: state.missions.map(m =>
32          m.id === action.id ? { ...m, priority: action.priority } : m
33        ),
34      };
35    default:
36      return state;
37  }
38}
39
40// --- Context ---
41const MissionContext = createContext();
42
43function MissionProvider({ children }) {
44  const [state, dispatch] = useReducer(missionReducer, initialState);
45  return (
46    <MissionContext.Provider value={{ state, dispatch }}>
47      {children}
48    </MissionContext.Provider>
49  );
50}
51
52function useMission() {
53  return useContext(MissionContext);
54}
55
56// --- Composants ---
57const priorityColors = { high: '#f44336', medium: '#ff9800', low: '#4caf50' };
58
59function MissionList() {
60  const { state, dispatch } = useMission();
61  return (
62    <div style={panel}>
63      <h2 style={{ color: '#4fc3f7', marginTop: 0 }}>Liste des missions</h2>
64      {state.missions.map(m => (
65        <div key={m.id} style={{ ...missionItem, opacity: m.completed ? 0.5 : 1 }}>
66          <span style={{ flex: 1 }}>{m.name}</span>
67          <span style={{ color: priorityColors[m.priority], marginRight: 8 }}>
68            [{m.priority}]
69          </span>
70          {!m.completed && (
71            <>
72              <select
73                value={m.priority}
74                onChange={e => dispatch({ type: 'SET_PRIORITY', id: m.id, priority: e.target.value })}
75                style={selectStyle}
76              >
77                <option value="high">Élevée</option>
78                <option value="medium">Moyenne</option>
79                <option value="low">Basse</option>
80              </select>
81              <button
82                onClick={() => dispatch({ type: 'COMPLETE_MISSION', id: m.id })}
83                style={btnStyle}
84              >
85                Terminer
86              </button>
87            </>
88          )}
89          {m.completed && <span style={{ color: '#4caf50' }}>Terminée</span>}
90        </div>
91      ))}
92    </div>
93  );
94}
95
96function AddMission() {
97  const { dispatch } = useMission();
98  const [name, setName] = React.useState('');
99  const [priority, setPriority] = React.useState('medium');
100
101  const handleAdd = () => {
102    if (!name.trim()) return;
103    dispatch({ type: 'ADD_MISSION', name, priority });
104    setName('');
105  };
106
107  return (
108    <div style={panel}>
109      <h2 style={{ color: '#4fc3f7', marginTop: 0 }}>Nouvelle mission</h2>
110      <input
111        value={name}
112        onChange={e => setName(e.target.value)}
113        placeholder="Nom de la mission..."
114        style={inputStyle}
115      />
116      <select value={priority} onChange={e => setPriority(e.target.value)} style={selectStyle}>
117        <option value="high">Priorité élevée</option>
118        <option value="medium">Priorité moyenne</option>
119        <option value="low">Priorité basse</option>
120      </select>
121      <button onClick={handleAdd} style={{ ...btnStyle, background: '#4fc3f7', color: '#0a0e1a' }}>
122        Ajouter la mission
123      </button>
124    </div>
125  );
126}
127
128function MissionStats() {
129  const { state } = useMission();
130  const total = state.missions.length;
131  const done = state.missions.filter(m => m.completed).length;
132  return (
133    <div style={{ ...panel, display: 'flex', gap: 20 }}>
134      <span style={{ color: '#adb5bd' }}>Total : <b style={{ color: 'white' }}>{total}</b></span>
135      <span style={{ color: '#adb5bd' }}>Terminées : <b style={{ color: '#4caf50' }}>{done}</b></span>
136      <span style={{ color: '#adb5bd' }}>Actives : <b style={{ color: '#4fc3f7' }}>{total - done}</b></span>
137    </div>
138  );
139}
140
141export default function App() {
142  return (
143    <MissionProvider>
144      <div style={{ minHeight: '100vh', background: '#0a0e1a', padding: 24, fontFamily: 'Arial', color: 'white' }}>
145        <h1 style={{ textAlign: 'center', color: '#ffd700', marginBottom: 24 }}>
146          Centre de commandement des missions
147        </h1>
148        <MissionStats />
149        <AddMission />
150        <MissionList />
151      </div>
152    </MissionProvider>
153  );
154}
155
156// --- Styles ---
157const panel = {
158  background: '#1a2035',
159  borderRadius: 10,
160  padding: 16,
161  marginBottom: 16,
162  border: '1px solid rgba(79,195,247,0.2)',
163};
164const missionItem = {
165  display: 'flex', alignItems: 'center', gap: 8,
166  padding: '8px 0', borderBottom: '1px solid rgba(255,255,255,0.07)',
167};
168const btnStyle = {
169  background: 'rgba(255,255,255,0.1)', color: 'white',
170  border: '1px solid rgba(255,255,255,0.2)', borderRadius: 6,
171  padding: '4px 10px', cursor: 'pointer',
172};
173const selectStyle = {
174  background: '#0a0e1a', color: 'white',
175  border: '1px solid rgba(79,195,247,0.3)', borderRadius: 6, padding: '4px 6px',
176};
177const inputStyle = {
178  background: '#0a0e1a', color: 'white',
179  border: '1px solid rgba(79,195,247,0.3)', borderRadius: 6,
180  padding: '6px 10px', width: '100%', marginBottom: 8, boxSizing: 'border-box',
181};

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. Pourquoi les fonctions reducer doivent-elles être des « fonctions pures » et ne pas muter l'état ?

  2. 2. Comment implémenter l'initialisation paresseuse dans useReducer ?

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne la progression de la complexité de l'état - du plus simple à celui qui nécessite useReducer :

  • Tri horizontal

    Ordonne les éléments de la syntaxe case d'un reducer de gauche à droite :

  • Éditeur de code

    Implémente un portail intergalactique

  • Clique dans l'ordre

    Ordonne la structure d'un reducer spatial :

  • Éditeur de code

    Construis un centre commercial galactique

Articles utiles