Cours JavaScript et React · Module 4: Context API et useReducer
Context et useReducer en pratique
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
dispatchdisponibles à 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. Pourquoi les fonctions reducer doivent-elles être des « fonctions pures » et ne pas muter l'état ?
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