Cours JavaScript et React · Module 10: Écosystème et avenir de React
Projet principal : Centre de commandement de la mission spatiale
Dans cette leçon6
Félicitations, astronaute ! Tu as atteint la mission finale du module React Mastery. Il est temps de combiner toutes les technologies que tu as apprises en un seul système puissant - le Centre de commandement de la mission spatiale. Ce projet est ton épreuve ultime pour prouver que tu maîtrises les patterns React avancés.
Objectif du projet
Tu vas construire un tableau de bord de gestion d'une mission spatiale qui combine les concepts clés de tout le module :
1. React Portals - Fenêtres modales d'alerte
Utilise les React Portals pour rendre les fenêtres modales d'alerte de mission en dehors de l'arbre DOM principal. Les alertes doivent apparaître au-dessus de l'interface avec un effet d'overlay.
2. Internationalisation (i18n)
Ajoute un sélecteur de langue (FR/EN) dans l'en-tête. L'ensemble du tableau de bord doit changer dynamiquement de langue d'interface en utilisant la Context API et des objets de traduction.
3. Gestion d'état avec useReducer
Gère l'état de la mission (statut, carburant, équipage, systèmes) à l'aide de useReducer. Définis des actions comme LAUNCH_MISSION, UPDATE_FUEL, TOGGLE_SYSTEM, ADD_CREW_MEMBER.
4. Compound Components
Crée un composant <Dashboard.Panel> à l'aide du pattern Compound Components qui permet une composition flexible des sections du tableau de bord :
1<Dashboard>
2 <Dashboard.Header title="Mission Control" />
3 <Dashboard.Panel title="Systems">
4 <SystemsList />
5 </Dashboard.Panel>
6 <Dashboard.Panel title="Crew">
7 <CrewList />
8 </Dashboard.Panel>
9</Dashboard>5. Error Boundaries
Enveloppe les sections du tableau de bord dans des Error Boundaries afin qu'une panne dans un panneau ne fasse pas planter toute l'application. Affiche un message d'erreur convivial avec une option pour recharger la section.
6. Optimisation des performances
Applique React.memo aux composants de liste, useMemo pour les calculs de statistiques de mission et useCallback pour les gestionnaires d'événements transmis aux composants enfants.
Structure de l'application
Avant d'écrire la première ligne de code, dessine l'arbre des composants. Les fournisseurs de contexte se trouvent tout en haut pour que chaque panneau puisse lire la langue et l'état de la mission, et chaque panneau a son propre Error Boundary, donc la panne de l'un n'arrête pas les autres. La fenêtre d'alerte est rendue via un portal, en dehors de l'arbre du tableau de bord :
1App
2├── LanguageProvider (Context - i18n)
3├── MissionProvider (useReducer - état)
4├── Dashboard (Compound Component)
5│ ├── Dashboard.Header
6│ ├── Dashboard.Panel "Statut de la mission"
7│ │ └── ErrorBoundary
8│ │ └── MissionStatus (React.memo)
9│ ├── Dashboard.Panel "Systèmes"
10│ │ └── ErrorBoundary
11│ │ └── SystemsList (React.memo)
12│ └── Dashboard.Panel "Équipage"
13│ └── ErrorBoundary
14│ └── CrewList (React.memo)
15└── Portal -> AlertModal (React Portal)Exigences
- Le tableau de bord affiche 3 panneaux : Statut de la mission, Systèmes, Équipage
- Le sélecteur de langue (FR/EN) change tous les libellés de l'interface
- Le bouton « Lancer la mission » change le statut et consomme du carburant (useReducer)
- Les systèmes peuvent être activés/désactivés
- La modale d'alerte (Portal) apparaît lorsque le carburant passe sous les 20 %
- L'Error Boundary affiche un contenu de secours lorsqu'un composant lève une erreur
- La mémoïsation évite les rendus inutiles
Indices d'implémentation
Reducer pour l'état de la mission
Tout l'état de la mission vit dans un seul reducer, une fonction pure qui calcule le nouvel état à partir de l'état précédent et d'une action. Il ne modifie pas l'objet state, il renvoie une copie avec les champs mis à jour, ce qui permet à React de savoir que quelque chose a changé et à React.memo d'ignorer les panneaux dont les données n'ont pas changé :
1const initialState = {
2 status: 'ready',
3 fuel: 100,
4 systems: [
5 { id: 1, name: 'Navigation', active: true },
6 { id: 2, name: 'Support vital', active: true },
7 { id: 3, name: 'Communications', active: false },
8 ],
9 crew: ['Kowalski', 'Nowak', 'Smith'],
10};
11
12function missionReducer(state, action) {
13 switch (action.type) {
14 case 'LAUNCH_MISSION':
15 return { ...state, status: 'inProgress', fuel: Math.max(0, state.fuel - 20) };
16 case 'UPDATE_FUEL':
17 return { ...state, fuel: Math.max(0, state.fuel + action.payload) };
18 case 'TOGGLE_SYSTEM':
19 return {
20 ...state,
21 systems: state.systems.map(s =>
22 s.id === action.payload ? { ...s, active: !s.active } : s
23 ),
24 };
25 case 'ADD_CREW_MEMBER':
26 return { ...state, crew: [...state.crew, action.payload] };
27 default:
28 return state;
29 }
30}Les composants ne modifient pas l'état eux-mêmes, ils envoient des actions, par exemple dispatch({ type: 'TOGGLE_SYSTEM', payload: 2 }). Math.max veille à ce que le carburant ne descende jamais sous zéro.
Contexte pour l'i18n
Les traductions sont de simples objets, un par langue. Le fournisseur de contexte garde la langue courante dans l'état et expose la fonction setLang, et les composants lisent les textes via leur propre hook useLanguage, donc changer de langue redessine immédiatement tous les libellés :
1const translations = {
2 fr: { launch: 'Lancer la mission', fuel: 'Carburant', systems: 'Systèmes' },
3 en: { launch: 'Launch Mission', fuel: 'Fuel', systems: 'Systems' },
4};
5
6const LanguageContext = createContext();
7
8function LanguageProvider({ children }) {
9 const [lang, setLang] = useState('fr');
10 const t = translations[lang];
11 return (
12 <LanguageContext.Provider value={{ lang, setLang, t }}>
13 {children}
14 </LanguageContext.Provider>
15 );
16}
17
18function useLanguage() {
19 return useContext(LanguageContext);
20}Portal pour les alertes
L'alerte doit couvrir toute l'interface, et un parent avec overflow: hidden ou son propre z-index peut la couper. createPortal rend la fenêtre dans un autre nœud du DOM, mais les événements et le contexte continuent de circuler dans l'arbre React, donc un bouton de la fenêtre peut appeler dispatch ou lire les traductions :
1function AlertModal({ message, onClose }) {
2 return createPortal(
3 <div className="modal-overlay">
4 <div className="modal-content">
5 <h2>Alerte !</h2>
6 <p>{message}</p>
7 <button onClick={onClose}>Fermer</button>
8 </div>
9 </div>,
10 document.getElementById('modal-root')
11 );
12}Tu ajoutes le nœud #modal-root dans index.html à côté de #root. Dans l'éditeur ci-dessous, le portal va simplement dans document.body.
Plan de travail
Le plus simple est de construire le projet par couches et de vérifier que tout fonctionne après chaque étape :
- Commence par
MissionProvideravec son reducer et un panneau qui affiche le statut et le carburant. - Ajoute
LanguageProvideret fais passer tous les libellés par la fonction de traduction. - Construis
DashboardavecDashboard.HeaderetDashboard.Panel, et place unErrorBoundarydans chaque panneau. - Ajoute l'alerte dans un portal, qui apparaît quand le carburant passe sous les 20 %.
- Enfin, mesure les rendus dans les React DevTools et ajoute
React.memo,useMemoetuseCallbacklà où ils économisent vraiment du travail.
Critères d'évaluation
- Les 6 patterns (Portals, i18n, useReducer, Compound Components, Error Boundaries, mémoïsation) doivent être implémentés
- L'application doit fonctionner sans erreurs dans la console
- Le changement de langue doit mettre à jour tous les libellés
- L'alerte doit apparaître automatiquement lorsque le carburant est faible
L'éditeur ci-dessous contient un squelette fonctionnel du centre de commandement avec les six patterns. Change de langue, lance la mission, brûle le carburant sous les 20 % et simule une panne du panneau des systèmes pour voir chaque pattern en action. Ensuite, enrichis l'application avec tes propres idées, par exemple une action qui termine la mission, des statistiques de flotte calculées avec useMemo ou une troisième langue, et compare-la avec la structure décrite ci-dessus.
Code de cette leçon : App.jsx
1import { Component, createContext, memo, useCallback, useContext, useMemo, useReducer, useState } from 'react';
2import { createPortal } from 'react-dom';
3import './App.css';
4
5// === I18N : Context avec les traductions ===
6const translations = {
7 fr: {
8 title: 'Centre de commandement de mission',
9 status: 'Statut de la mission',
10 systems: 'Systèmes',
11 crew: 'Équipage',
12 fuel: 'Carburant',
13 launch: 'Lancer la mission',
14 abort: 'Interrompre la mission',
15 burnFuel: '-10 % de carburant',
16 ready: 'Prêts au décollage',
17 inProgress: 'Mission en cours',
18 lowFuel: 'Attention ! Niveau de carburant bas !',
19 alertTitle: 'Alerte',
20 dismiss: 'Fermer',
21 addMember: 'Ajouter un membre',
22 namePlaceholder: 'Prénom de l’astronaute',
23 active: 'actifs',
24 on: 'MARCHE',
25 off: 'ARRÊT',
26 engine: 'Moteur',
27 shields: 'Boucliers',
28 comms: 'Communications',
29 navigation: 'Navigation',
30 commander: 'Commandant',
31 navigator: 'Navigateur',
32 recruit: 'Recrue',
33 crashed: 'Le panneau est tombé en panne',
34 reload: 'Recharger',
35 simulateCrash: 'Simuler une panne du panneau',
36 },
37 en: {
38 title: 'Mission Command Center',
39 status: 'Mission status',
40 systems: 'Systems',
41 crew: 'Crew',
42 fuel: 'Fuel',
43 launch: 'Launch mission',
44 abort: 'Abort mission',
45 burnFuel: '-10% fuel',
46 ready: 'Ready for launch',
47 inProgress: 'Mission in progress',
48 lowFuel: 'Warning! Low fuel level!',
49 alertTitle: 'Alert',
50 dismiss: 'Dismiss',
51 addMember: 'Add member',
52 namePlaceholder: 'Astronaut name',
53 active: 'active',
54 on: 'ON',
55 off: 'OFF',
56 engine: 'Engine',
57 shields: 'Shields',
58 comms: 'Communications',
59 navigation: 'Navigation',
60 commander: 'Commander',
61 navigator: 'Navigator',
62 recruit: 'Recruit',
63 crashed: 'The panel has crashed',
64 reload: 'Reload',
65 simulateCrash: 'Simulate a panel failure',
66 },
67};
68
69const FIRST_LANG = 'fr';
70const SECOND_LANG = 'en';
71
72const LanguageContext = createContext(null);
73
74function LanguageProvider({ children }) {
75 const [lang, setLang] = useState(FIRST_LANG);
76 const t = useCallback(key => translations[lang][key] ?? key, [lang]);
77 const toggleLang = useCallback(() => setLang(l => (l === FIRST_LANG ? SECOND_LANG : FIRST_LANG)), []);
78 const value = useMemo(() => ({ lang, t, toggleLang }), [lang, t, toggleLang]);
79
80 return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>;
81}
82
83function useLanguage() {
84 return useContext(LanguageContext);
85}
86
87// === ÉTAT DE LA MISSION : useReducer ===
88const initialMission = {
89 status: 'ready',
90 fuel: 100,
91 systems: [
92 { id: 1, key: 'engine', active: true },
93 { id: 2, key: 'shields', active: false },
94 { id: 3, key: 'comms', active: true },
95 { id: 4, key: 'navigation', active: true },
96 ],
97 crew: [
98 { id: 1, name: 'Ra', role: 'commander' },
99 { id: 2, name: 'Astro', role: 'navigator' },
100 ],
101};
102
103function missionReducer(state, action) {
104 switch (action.type) {
105 case 'LAUNCH_MISSION':
106 return { ...state, status: 'inProgress', fuel: Math.max(0, state.fuel - 15) };
107 case 'ABORT_MISSION':
108 return { ...state, status: 'ready' };
109 case 'UPDATE_FUEL':
110 return { ...state, fuel: Math.max(0, state.fuel + action.payload) };
111 case 'TOGGLE_SYSTEM':
112 return {
113 ...state,
114 systems: state.systems.map(s => (s.id === action.payload ? { ...s, active: !s.active } : s)),
115 };
116 case 'ADD_CREW_MEMBER':
117 return { ...state, crew: [...state.crew, { ...action.payload, role: 'recruit' }] };
118 default:
119 return state;
120 }
121}
122
123const MissionContext = createContext(null);
124
125function MissionProvider({ children }) {
126 const [state, dispatch] = useReducer(missionReducer, initialMission);
127 const value = useMemo(() => ({ state, dispatch }), [state]);
128 return <MissionContext.Provider value={value}>{children}</MissionContext.Provider>;
129}
130
131function useMission() {
132 return useContext(MissionContext);
133}
134
135// === ERROR BOUNDARY : la panne d’un panneau n’arrête pas les autres ===
136class ErrorBoundary extends Component {
137 constructor(props) {
138 super(props);
139 this.state = { hasError: false };
140 }
141
142 static getDerivedStateFromError() {
143 return { hasError: true };
144 }
145
146 render() {
147 if (this.state.hasError) {
148 return (
149 <div className="error-fallback">
150 <p>{this.props.message}</p>
151 <button onClick={() => this.setState({ hasError: false })}>{this.props.retryLabel}</button>
152 </div>
153 );
154 }
155 return this.props.children;
156 }
157}
158
159// === COMPOUND COMPONENT : Dashboard, Dashboard.Header, Dashboard.Panel ===
160function Dashboard({ children }) {
161 return <div className="dashboard">{children}</div>;
162}
163
164Dashboard.Header = memo(function DashboardHeader() {
165 const { t, lang, toggleLang } = useLanguage();
166 const next = lang === FIRST_LANG ? SECOND_LANG : FIRST_LANG;
167
168 return (
169 <header className="dashboard-header">
170 <h1>{t('title')}</h1>
171 <button className="lang-btn" onClick={toggleLang}>{next.toUpperCase()}</button>
172 </header>
173 );
174});
175
176Dashboard.Panel = memo(function DashboardPanel({ title, children }) {
177 const { t } = useLanguage();
178
179 return (
180 <section className="dashboard-panel">
181 <h2>{title}</h2>
182 <ErrorBoundary message={t('crashed')} retryLabel={t('reload')}>
183 {children}
184 </ErrorBoundary>
185 </section>
186 );
187});
188
189// === STATUT DE LA MISSION ===
190const MissionStatus = memo(function MissionStatus() {
191 const { state, dispatch } = useMission();
192 const { t } = useLanguage();
193
194 const handleLaunch = useCallback(() => dispatch({ type: 'LAUNCH_MISSION' }), [dispatch]);
195 const handleAbort = useCallback(() => dispatch({ type: 'ABORT_MISSION' }), [dispatch]);
196 const handleBurn = useCallback(() => dispatch({ type: 'UPDATE_FUEL', payload: -10 }), [dispatch]);
197
198 return (
199 <div>
200 <div className="status-indicator">
201 <span className={'status-dot status-' + state.status} />
202 <span>{t(state.status)}</span>
203 </div>
204 <div className="fuel-bar">
205 <span>{t('fuel')}: {state.fuel}%</span>
206 <div className="fuel-track">
207 <div className={'fuel-fill' + (state.fuel < 20 ? ' fuel-low' : '')} style={{ width: state.fuel + '%' }} />
208 </div>
209 </div>
210 <div className="status-actions">
211 {state.status === 'ready' && <button className="btn-launch" onClick={handleLaunch}>{t('launch')}</button>}
212 {state.status === 'inProgress' && (
213 <>
214 <button className="btn-abort" onClick={handleAbort}>{t('abort')}</button>
215 <button className="btn-fuel" onClick={handleBurn}>{t('burnFuel')}</button>
216 </>
217 )}
218 </div>
219 </div>
220 );
221});
222
223// Un capteur qui lève une erreur pendant le rendu à la demande (test de l’Error Boundary)
224function SensorArray({ broken }) {
225 if (broken) {
226 throw new Error('Panne des capteurs des systèmes');
227 }
228 return null;
229}
230
231// === LISTE DES SYSTÈMES ===
232const SystemsList = memo(function SystemsList() {
233 const { state, dispatch } = useMission();
234 const { t } = useLanguage();
235 const [broken, setBroken] = useState(false);
236
237 const handleToggle = useCallback(id => dispatch({ type: 'TOGGLE_SYSTEM', payload: id }), [dispatch]);
238 const activeCount = useMemo(() => state.systems.filter(s => s.active).length, [state.systems]);
239
240 return (
241 <div>
242 <SensorArray broken={broken} />
243 <p className="systems-count">{activeCount}/{state.systems.length} {t('active')}</p>
244 {state.systems.map(system => (
245 <div key={system.id} className="system-item">
246 <span>{t(system.key)}</span>
247 <button className={system.active ? 'sys-on' : 'sys-off'} onClick={() => handleToggle(system.id)}>
248 {system.active ? t('on') : t('off')}
249 </button>
250 </div>
251 ))}
252 <button className="btn-crash" onClick={() => setBroken(true)}>{t('simulateCrash')}</button>
253 </div>
254 );
255});
256
257// === LISTE DE L’ÉQUIPAGE ===
258const CrewList = memo(function CrewList() {
259 const { state, dispatch } = useMission();
260 const { t } = useLanguage();
261 const [name, setName] = useState('');
262
263 const handleAdd = useCallback(() => {
264 if (!name.trim()) return;
265 // L’identifiant est créé dans le handler, car le reducer doit rester une fonction pure
266 dispatch({ type: 'ADD_CREW_MEMBER', payload: { id: Date.now(), name: name.trim() } });
267 setName('');
268 }, [name, dispatch]);
269
270 return (
271 <div>
272 {state.crew.map(member => (
273 <div key={member.id} className="crew-item">
274 <strong>{member.name}</strong>
275 <span>{t(member.role)}</span>
276 </div>
277 ))}
278 <div className="crew-add">
279 <input
280 value={name}
281 onChange={e => setName(e.target.value)}
282 onKeyDown={e => e.key === 'Enter' && handleAdd()}
283 placeholder={t('namePlaceholder')}
284 />
285 <button onClick={handleAdd}>{t('addMember')}</button>
286 </div>
287 </div>
288 );
289});
290
291// === ALERTE DANS UN PORTAL : rendue hors de l’arbre du tableau de bord ===
292function AlertModal({ onDismiss }) {
293 const { t } = useLanguage();
294
295 return createPortal(
296 <div className="modal-overlay">
297 <div className="modal-content">
298 <h2>{t('alertTitle')}</h2>
299 <p>{t('lowFuel')}</p>
300 <button onClick={onDismiss}>{t('dismiss')}</button>
301 </div>
302 </div>,
303 document.body
304 );
305}
306
307// === APPLICATION PRINCIPALE ===
308function MissionControl() {
309 const { state } = useMission();
310 const { t } = useLanguage();
311 const [dismissedAt, setDismissedAt] = useState(null);
312
313 // L’alerte découle de l’état : carburant bas pendant la mission, sauf si on l’a fermée à ce niveau
314 const showAlert = state.status === 'inProgress' && state.fuel < 20 && dismissedAt !== state.fuel;
315
316 return (
317 <>
318 <Dashboard>
319 <Dashboard.Header />
320 <div className="panels-grid">
321 <Dashboard.Panel title={t('status')}>
322 <MissionStatus />
323 </Dashboard.Panel>
324 <Dashboard.Panel title={t('systems')}>
325 <SystemsList />
326 </Dashboard.Panel>
327 <Dashboard.Panel title={t('crew')}>
328 <CrewList />
329 </Dashboard.Panel>
330 </div>
331 </Dashboard>
332 {showAlert && <AlertModal onDismiss={() => setDismissedAt(state.fuel)} />}
333 </>
334 );
335}
336
337export default function App() {
338 return (
339 <LanguageProvider>
340 <MissionProvider>
341 <MissionControl />
342 </MissionProvider>
343 </LanguageProvider>
344 );
345}Tu as repéré une erreur dans cette leçon ?