Cours JavaScript et React · Module 4: Context API et useReducer
Projet : Centre de commandement galactique
Dans cette leçon5
Tout au long de ce module, tu as construit tes connaissances étape par étape : Context API, reducers, combinaison de ces outils, optimisation. Il est maintenant temps de tout rassembler dans un seul projet - le Centre de commandement galactique.
Ce n'est pas une énième leçon théorique. C'est un projet que tu vas construire toi-même, en utilisant tout ce que tu as appris.
Que vas-tu construire ?
Une application gérant une flotte de vaisseaux spatiaux avec trois systèmes indépendants, chacun ayant son propre contexte :
- ThemeContext - un commutateur de thème (mode jour « Lever du soleil » / mode nuit « Espace profond »)
- AuthContext - un système d'autorisation (Capitaine connecté / déconnecté)
- MissionContext - le cœur de l'application : la gestion des missions via useReducer
Les composants à l'écran consommeront différents contextes selon leurs besoins.
Exigences du projet
1. ThemeContext
Le contexte stocke le thème actuel et une fonction de bascule. Le composant ThemeToggle change le thème, et le reste de l'application réagit au changement (par ex. une classe CSS sur le conteneur principal).
1// Structure de contexte attendue
2{
3 theme: 'dark' | 'light',
4 toggleTheme: () => void
5}2. AuthContext
Le contexte stocke les données du capitaine et permet la connexion/déconnexion. Le formulaire de connexion doit accepter le nom du capitaine. Après la connexion, affiche un message de bienvenue et un bouton de déconnexion.
1// Structure de contexte attendue
2{
3 captain: { name: string } | null,
4 login: (name: string) => void,
5 logout: () => void
6}3. MissionContext avec useReducer
C'est la partie principale du projet. Le reducer gère au moins 4 types d'actions :
1// Types d'actions à implémenter
2'ADD_MISSION' // ajoute une nouvelle mission à la liste
3'UPDATE_STATUS' // change le statut d'une mission existante
4'ASSIGN_SHIP' // assigne un vaisseau à une mission
5'COMPLETE_MISSION' // marque une mission comme terminée
6
7// Exemple de forme de l'état
8{
9 missions: [
10 {
11 id: 1,
12 name: 'Mars Exploration',
13 status: 'planning' | 'active' | 'completed',
14 assignedShip: string | null
15 }
16 ],
17 availableShips: ['USS Nova', 'Orion-7', 'Celestia']
18}Structure des composants
L'application doit se composer des composants suivants :
1function App() {
2 return (
3 <ThemeProvider>
4 <AuthProvider>
5 <MissionProvider>
6 <div className="app">
7 <Header /> {/* utilise AuthContext + ThemeContext */}
8 <MissionDashboard /> {/* utilise MissionContext + AuthContext */}
9 <ThemeToggle /> {/* utilise ThemeContext */}
10 </div>
11 </MissionProvider>
12 </AuthProvider>
13 </ThemeProvider>
14 );
15}Header - affiche le titre de l'application, le statut de connexion (depuis AuthContext) et le thème actuel.
MissionDashboard - liste des missions, formulaire pour ajouter une nouvelle mission, assignation de vaisseaux. Accessible uniquement aux capitaines connectés.
ThemeToggle - un bouton qui bascule entre les modes jour et nuit.
Conseils d'implémentation
Commence par construire chaque provider séparément et teste-le avant de tous les combiner. Un bon ordre est : ThemeProvider (le plus simple) -> AuthProvider -> MissionProvider.
N'oublie pas les custom hooks pour chaque contexte :
1function useTheme() {
2 const ctx = useContext(ThemeContext);
3 if (!ctx) throw new Error('useTheme must be inside ThemeProvider');
4 return ctx;
5}
6
7function useAuth() {
8 const ctx = useContext(AuthContext);
9 if (!ctx) throw new Error('useAuth must be inside AuthProvider');
10 return ctx;
11}
12
13function useMissions() {
14 const ctx = useContext(MissionContext);
15 if (!ctx) throw new Error('useMissions must be inside MissionProvider');
16 return ctx;
17}Pour MissionContext, applique le pattern de découpage en un contexte d'état et un contexte de dispatch que tu as appris dans la leçon précédente - c'est une bonne pratique pour un état complexe utilisant useReducer.
Ce qu'il faut démontrer dans le projet
Le projet terminé doit montrer :
- Trois systèmes de contexte indépendants fonctionnant simultanément
- Le composant
Headerconsommant à la foisAuthContextetThemeContext - Les changements de thème immédiatement visibles dans toute l'application
- L'ajout de missions et le changement de leur statut via dispatch
- Le blocage du panneau de missions pour les utilisateurs non authentifiés
- L'assignation d'un vaisseau à une mission (action
ASSIGN_SHIPavec payload)
Le projet est volontairement décrit de manière large - c'est ta chance d'aborder la solution de façon créative. Utilise l'éditeur ci-dessous pour construire ta version du Centre de commandement galactique.
Code de cette leçon : App.jsx
1import React, { createContext, useContext, useReducer, useState } from 'react';
2
3// ============================================================
4// PROJET : Centre de commandement galactique
5// Tâche : Complète les fragments de code manquants (marqués TODO)
6// ============================================================
7
8// --- 1. THEME CONTEXT ---
9const ThemeContext = createContext();
10
11function ThemeProvider({ children }) {
12 const [darkMode, setDarkMode] = useState(true);
13 // TODO : Ajoute aussi la fonction toggleTheme à value
14 return (
15 <ThemeContext.Provider value={{ darkMode, toggleTheme: () => setDarkMode(d => !d) }}>
16 {children}
17 </ThemeContext.Provider>
18 );
19}
20
21function useTheme() {
22 // TODO : Utilise useContext pour lire ThemeContext
23 return useContext(ThemeContext);
24}
25
26// --- 2. AUTH CONTEXT ---
27const AuthContext = createContext();
28
29const authInitialState = { user: null, isLoggedIn: false };
30
31function authReducer(state, action) {
32 switch (action.type) {
33 case 'LOGIN':
34 // TODO : Retourne un nouvel état avec user: action.user et isLoggedIn: true
35 return { ...state, user: action.user, isLoggedIn: true };
36 case 'LOGOUT':
37 return authInitialState;
38 default:
39 return state;
40 }
41}
42
43function AuthProvider({ children }) {
44 const [state, dispatch] = useReducer(authReducer, authInitialState);
45 return (
46 <AuthContext.Provider value={{ ...state, dispatch }}>
47 {children}
48 </AuthContext.Provider>
49 );
50}
51
52function useAuth() { return useContext(AuthContext); }
53
54// --- 3. MISSION CONTEXT ---
55const MissionContext = createContext();
56
57const missionInitialState = { missions: [] };
58
59function missionReducer(state, action) {
60 switch (action.type) {
61 case 'ADD_MISSION':
62 // TODO : Ajoute une nouvelle mission au tableau missions
63 // La mission a les champs : id, name, status: 'pending', createdBy
64 return {
65 ...state,
66 missions: [
67 ...state.missions,
68 { id: Date.now(), name: action.name, status: 'pending', createdBy: action.createdBy },
69 ],
70 };
71 case 'UPDATE_STATUS':
72 return {
73 ...state,
74 missions: state.missions.map(m =>
75 m.id === action.id ? { ...m, status: action.status } : m
76 ),
77 };
78 default:
79 return state;
80 }
81}
82
83function MissionProvider({ children }) {
84 const [state, dispatch] = useReducer(missionReducer, missionInitialState);
85 // TODO : Enveloppe children dans MissionContext.Provider avec value={{ ...state, dispatch }}
86 return (
87 <MissionContext.Provider value={{ ...state, dispatch }}>
88 {children}
89 </MissionContext.Provider>
90 );
91}
92
93function useMission() { return useContext(MissionContext); }
94
95// --- COMPOSANTS UI ---
96
97function LoginPanel() {
98 const { isLoggedIn, user, dispatch } = useAuth();
99 const [name, setName] = useState('');
100 if (isLoggedIn) {
101 return (
102 <div style={panel}>
103 <span style={{ color: '#4fc3f7' }}>Connecté : <b>{user}</b></span>
104 <button onClick={() => dispatch({ type: 'LOGOUT' })} style={{ ...btn, marginLeft: 12 }}>
105 Déconnexion
106 </button>
107 </div>
108 );
109 }
110 return (
111 <div style={panel}>
112 <input
113 value={name} onChange={e => setName(e.target.value)}
114 placeholder="Ton nom (pilote)..." style={input}
115 />
116 <button
117 onClick={() => { if (name.trim()) dispatch({ type: 'LOGIN', user: name.trim() }); }}
118 style={{ ...btn, background: '#4fc3f7', color: '#0a0e1a' }}
119 >
120 Se connecter
121 </button>
122 </div>
123 );
124}
125
126function MissionPanel() {
127 const { isLoggedIn, user } = useAuth();
128 const { missions, dispatch } = useMission();
129 const [newMission, setNewMission] = useState('');
130 const statusLabels = { pending: '⏳ En attente', active: 'Active', done: 'Terminée' };
131 const statusColors = { pending: '#ff9800', active: '#4fc3f7', done: '#4caf50' };
132
133 if (!isLoggedIn) {
134 return (
135 <div style={{ ...panel, color: '#778da9', textAlign: 'center' }}>
136 Connecte-toi pour gérer les missions
137 </div>
138 );
139 }
140
141 return (
142 <div style={panel}>
143 <h3 style={{ color: '#4fc3f7', marginTop: 0 }}>Missions spatiales</h3>
144 <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
145 <input
146 value={newMission} onChange={e => setNewMission(e.target.value)}
147 placeholder="Nom de la mission..." style={{ ...input, flex: 1 }}
148 />
149 <button
150 onClick={() => {
151 // TODO : Appelle dispatch avec type: 'ADD_MISSION', name: newMission, createdBy: user
152 if (newMission.trim()) {
153 dispatch({ type: 'ADD_MISSION', name: newMission.trim(), createdBy: user });
154 setNewMission('');
155 }
156 }}
157 style={{ ...btn, background: '#4fc3f7', color: '#0a0e1a' }}
158 >
159 Ajouter
160 </button>
161 </div>
162 {missions.length === 0 && (
163 <p style={{ color: '#555', textAlign: 'center' }}>Aucune mission. Ajoute la première !</p>
164 )}
165 {missions.map(m => (
166 <div key={m.id} style={{ ...missionRow }}>
167 <div>
168 <div style={{ fontWeight: 'bold' }}>{m.name}</div>
169 <div style={{ fontSize: 11, color: '#778da9' }}>Pilote : {m.createdBy}</div>
170 </div>
171 <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
172 <span style={{ color: statusColors[m.status], fontSize: 12 }}>
173 {statusLabels[m.status]}
174 </span>
175 {m.status === 'pending' && (
176 <button onClick={() => dispatch({ type: 'UPDATE_STATUS', id: m.id, status: 'active' })} style={btn}>
177 Activer
178 </button>
179 )}
180 {m.status === 'active' && (
181 <button onClick={() => dispatch({ type: 'UPDATE_STATUS', id: m.id, status: 'done' })} style={btn}>
182 Terminer
183 </button>
184 )}
185 </div>
186 </div>
187 ))}
188 </div>
189 );
190}
191
192function ThemeToggle() {
193 const { darkMode, toggleTheme } = useTheme();
194 return (
195 <button onClick={toggleTheme} style={{ ...btn, float: 'right', marginBottom: 12 }}>
196 {darkMode ? 'Thème clair' : 'Thème sombre'}
197 </button>
198 );
199}
200
201// --- APPLICATION PRINCIPALE ---
202// TODO : Enveloppe App dans ThemeProvider, AuthProvider et MissionProvider (l'ordre compte !)
203function App() {
204 const { darkMode } = useTheme();
205 const bg = darkMode ? '#0a0e1a' : '#e8f4fd';
206 const fg = darkMode ? 'white' : '#0a0e1a';
207
208 return (
209 <div style={{ minHeight: '100vh', background: bg, padding: 24, fontFamily: 'Arial', color: fg, transition: 'all 0.3s' }}>
210 <ThemeToggle />
211 <h1 style={{ textAlign: 'center', color: '#ffd700', marginBottom: 24, clear: 'both' }}>
212 Centre de commandement galactique
213 </h1>
214 <LoginPanel />
215 <MissionPanel />
216 </div>
217 );
218}
219
220function WrappedApp() {
221 return (
222 <ThemeProvider>
223 <AuthProvider>
224 <MissionProvider>
225 <App />
226 </MissionProvider>
227 </AuthProvider>
228 </ThemeProvider>
229 );
230}
231
232// Styles
233const panel = {
234 background: '#1a2035', borderRadius: 10, padding: 16, marginBottom: 14,
235 border: '1px solid rgba(79,195,247,0.2)',
236};
237const btn = {
238 background: 'rgba(255,255,255,0.08)', color: 'white',
239 border: '1px solid rgba(255,255,255,0.2)', borderRadius: 6,
240 padding: '5px 12px', cursor: 'pointer', fontSize: 12,
241};
242const input = {
243 background: '#0a0e1a', color: 'white',
244 border: '1px solid rgba(79,195,247,0.3)', borderRadius: 6,
245 padding: '6px 10px', boxSizing: 'border-box',
246};
247const missionRow = {
248 display: 'flex', justifyContent: 'space-between', alignItems: 'center',
249 padding: '8px 0', borderBottom: '1px solid rgba(255,255,255,0.07)',
250};
251
252export { WrappedApp as default };Tu as repéré une erreur dans cette leçon ?