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

Projet : Centre de commandement galactique

4 min de lecture
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 :

  1. Trois systèmes de contexte indépendants fonctionnant simultanément
  2. Le composant Header consommant à la fois AuthContext et ThemeContext
  3. Les changements de thème immédiatement visibles dans toute l'application
  4. L'ajout de missions et le changement de leur statut via dispatch
  5. Le blocage du panneau de missions pour les utilisateurs non authentifiés
  6. L'assignation d'un vaisseau à une mission (action ASSIGN_SHIP avec 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 ?

Articles utiles