Cours JavaScript et React · Module 10: Écosystème et avenir de React

Projet principal : Centre de commandement de la mission spatiale

6 min de lecture
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

  1. Le tableau de bord affiche 3 panneaux : Statut de la mission, Systèmes, Équipage
  2. Le sélecteur de langue (FR/EN) change tous les libellés de l'interface
  3. Le bouton « Lancer la mission » change le statut et consomme du carburant (useReducer)
  4. Les systèmes peuvent être activés/désactivés
  5. La modale d'alerte (Portal) apparaît lorsque le carburant passe sous les 20 %
  6. L'Error Boundary affiche un contenu de secours lorsqu'un composant lève une erreur
  7. 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 :

  1. Commence par MissionProvider avec son reducer et un panneau qui affiche le statut et le carburant.
  2. Ajoute LanguageProvider et fais passer tous les libellés par la fonction de traduction.
  3. Construis Dashboard avec Dashboard.Header et Dashboard.Panel, et place un ErrorBoundary dans chaque panneau.
  4. Ajoute l'alerte dans un portal, qui apparaît quand le carburant passe sous les 20 %.
  5. Enfin, mesure les rendus dans les React DevTools et ajoute React.memo, useMemo et useCallback là 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 ?

Articles utiles