Cours JavaScript et React · Module 15: Patterns et architecture
Projet final - Architecture de la station spatiale
Dans cette leçon5
Tu connais déjà plus d'une dizaine de patterns, mais tu les as pratiqués chacun séparément, comme des modules testés un par un dans un hangar. Une station orbitale ne fonctionne qu'une fois ses modules reliés. Il est temps de récapituler tout ce que nous avons appris dans ce module ! Ta mission est de concevoir et de construire un Mission Control Dashboard, un panneau de contrôle de mission spatiale qui utilise tous les patterns étudiés.
Exigences du projet
Ton projet doit utiliser :
- Composition : une mise en page à plusieurs slots (
header,sidebar,main) - Container/Presentational : la séparation de la logique et de l'UI
- Custom hooks : de la logique extraite (par ex.
useMission,useCrewFilter) - Compound Components : un système d'onglets ou un accordéon
- Atomic Design : l'organisation des composants en atomes, molécules, organismes
Le projet pour ton mentor ajoute au moins trois hooks personnels : useToggle, useFetch et useFilter.
Architecture du projet
Avant d'écrire la première ligne, planifie les dossiers. La structure ci-dessous range les composants selon les niveaux de l'Atomic Design et garde la logique à part, dans des hooks :
1MissionDashboard/
2 atoms/
3 StatusBadge - affiche le statut de la mission (active/completed/failed)
4 ProgressBar - barre de progression de la mission
5 IconButton - bouton avec icône
6 molecules/
7 MissionCard - carte de mission (StatusBadge + titre + ProgressBar)
8 CrewMember - membre d'équipage (avatar + nom + rôle)
9 organisms/
10 MissionList - liste de missions avec filtrage
11 CrewPanel - panneau d'équipage avec recherche
12 Tabs/Tab/Panel - compound component avec onglets
13 hooks/
14 useMission - logique de gestion des missions
15 useFilter - logique de filtrage
16 useToggle - bascule d'étatLes atomes ne connaissent pas les données, les molécules combinent des atomes, et seuls les organismes font appel aux hooks. Si un atome importe un hook de données, quelque chose cloche. React n'impose aucune structure de dossiers : dans les grandes applications, on regroupe souvent le code par fonctionnalité (features/missions, features/crew), et on réserve l'Atomic Design à la bibliothèque de composants partagée.
Patterns clés du projet
Custom Hook - useMission
Le cœur du panneau est un hook qui garde la liste des missions et le filtre actuel, et qui expose les données filtrées ainsi qu'une action pour changer un statut :
1function useMission() {
2 const [missions, setMissions] = useState(initialMissions);
3 const [filter, setFilter] = useState('all');
4
5 const filteredMissions = useMemo(() => {
6 if (filter === 'all') return missions;
7 return missions.filter(m => m.status === filter);
8 }, [missions, filter]);
9
10 const updateStatus = (id, status) => {
11 setMissions(prev =>
12 prev.map(m => m.id === id ? { ...m, status } : m)
13 );
14 };
15
16 return { missions: filteredMissions, filter, setFilter, updateStatus };
17}useMemo ne recalcule la liste que lorsque les missions ou le filtre changent. updateStatus crée un nouveau tableau avec map et ne modifie pas l'original. Dans l'exercice pratique, ce hook s'appelle useMissionData, mais la logique est la même.
Compound Components - Tabs
Les compound components forment une famille de composants qui fonctionnent ensemble et partagent un état caché, comme <select> et <option> en HTML. Voici comment les onglets sont utilisés dans le panneau :
1function MissionControl() {
2 return (
3 <Tabs defaultTab={0}>
4 <Tabs.TabList>
5 <Tabs.Tab index={0}>Missions</Tabs.Tab>
6 <Tabs.Tab index={1}>Équipage</Tabs.Tab>
7 <Tabs.Tab index={2}>Systèmes</Tabs.Tab>
8 </Tabs.TabList>
9
10 <Tabs.Panel index={0}>
11 <MissionList />
12 </Tabs.Panel>
13 <Tabs.Panel index={1}>
14 <CrewPanel />
15 </Tabs.Panel>
16 <Tabs.Panel index={2}>
17 <SystemsOverview />
18 </Tabs.Panel>
19 </Tabs>
20 );
21}Tabs.Tab et Tabs.Panel ne reçoivent pas l'onglet actif par les props, ils le lisent dans un contexte partagé créé par Tabs. Le squelette d'un tel ensemble est court :
1const TabsContext = createContext(null);
2
3function Tabs({ defaultTab = 0, children }) {
4 const [activeTab, setActiveTab] = useState(defaultTab);
5 return (
6 <TabsContext.Provider value={{ activeTab, setActiveTab }}>
7 {children}
8 </TabsContext.Provider>
9 );
10}
11
12Tabs.Tab = function Tab({ index, children }) {
13 const { activeTab, setActiveTab } = useContext(TabsContext);
14 return (
15 <button role="tab" aria-selected={activeTab === index} onClick={() => setActiveTab(index)}>
16 {children}
17 </button>
18 );
19};L'attribut aria-selected ne fonctionne qu'avec le rôle tab : grâce aux deux, un lecteur d'écran sait qu'il s'agit d'un onglet et lequel est actif. Tabs.TabList est un conteneur avec le rôle tablist, et Tabs.Panel n'affiche ses enfants, avec le rôle tabpanel, que si activeTab === index. MissionControl ne change pas, car le contexte est un détail interne de l'ensemble. Les onglets de Radix UI sont construits de la même manière et relient en plus chaque onglet à son panneau avec l'attribut aria-controls.
Composition - Dashboard Layout
L'ensemble tient grâce à la mise en page à trois slots que tu connais depuis la première leçon du module. L'en-tête, le panneau latéral et la zone principale reçoivent des éléments tout prêts de App :
1function DashboardLayout({ header, sidebar, children }) {
2 return (
3 <div className="dashboard">
4 <header className="dash-header">{header}</header>
5 <div className="dash-body">
6 <aside className="dash-sidebar">{sidebar}</aside>
7 <main className="dash-content">{children}</main>
8 </div>
9 </div>
10 );
11}
12
13function App() {
14 return (
15 <DashboardLayout
16 header={<TopBar title="Centre de contrôle de mission" />}
17 sidebar={<StatusPanel />}
18 >
19 <MissionControl />
20 </DashboardLayout>
21 );
22}DashboardLayout ne sait rien des missions ni des onglets, donc la même mise en page servira aussi pour le panneau de l'équipage.
Conseils d'implémentation
Étape 1 : Custom hooks
Commence par extraire la logique dans des custom hooks. Chaque hook doit avoir une seule responsabilité, comme cet interrupteur :
1// useToggle - simple bascule d'état
2function useToggle(initial = false) {
3 const [value, setValue] = useState(initial);
4 const toggle = useCallback(() => setValue(v => !v), []);
5 return [value, toggle];
6}useToggle renvoie une paire comme useState, et useCallback fait de toggle la même fonction d'un rendu à l'autre. Le second hook filtre n'importe quelle liste selon le texte saisi :
1// useFilter - logique de filtrage (sans debounce)
2function useFilter(items, filterFn) {
3 const [query, setQuery] = useState('');
4
5 const filteredItems = useMemo(() => {
6 if (!query) return items;
7 return items.filter(item => filterFn(item, query));
8 }, [items, query, filterFn]);
9
10 return { filteredItems, query, setQuery };
11}Ce hook n'a pas de debounce : il filtre à chaque frappe, ce qui suffit pour quelques dizaines de missions. Pour de longues listes, le hook intégré useDeferredValue aide à garder une saisie fluide. Définis filterFn hors du composant, car une nouvelle fonction à chaque rendu invalide useMemo. Tu écriras le troisième hook, useFetch(url), comme useCrew de la leçon sur Container/Presentational : un état data, loading et error, plus un useEffect qui récupère les données.
Étape 2 : Atomes et molécules
Construis des composants simples et réutilisables. Un atome ne sait rien des missions, il connaît seulement le statut qu'il doit afficher et la map statusLabels avec les libellés pour l'équipage :
1// Atome - StatusBadge
2const statusLabels = { active: 'En cours', completed: 'Terminée', failed: 'Échouée' };
3
4function StatusBadge({ status }) {
5 const colors = {
6 active: '#00e676',
7 completed: '#64b5f6',
8 failed: '#f44336',
9 };
10 return (
11 <span style={{ background: colors[status], padding: '4px 12px', borderRadius: '20px' }}>
12 {statusLabels[status]}
13 </span>
14 );
15}Les valeurs des statuts restent des codes, car la logique les compare, et l'équipage voit des libellés en français. Une molécule combine des atomes en quelque chose qui a déjà un sens : la carte d'une mission précise, avec son nom, son statut et sa barre de progression :
1// Molécule - MissionCard (combine des atomes)
2function MissionCard({ mission, onStatusChange }) {
3 return (
4 <div className="mission-card">
5 <h3>{mission.name}</h3>
6 <StatusBadge status={mission.status} />
7 <ProgressBar value={mission.progress} />
8 </div>
9 );
10}MissionCard accepte onStatusChange, mais ne l'utilise pas encore. Tu le brancheras dans le projet, par exemple sur un bouton qui marque une mission comme terminée. ProgressBar est le second atome, que tu connais déjà depuis la leçon sur l'Atomic Design.
Étape 3 : Organismes avec des données
Assemble les molécules en structures plus grandes. L'organisme est le premier à faire appel au hook de données et distribue celles-ci aux cartes :
1// MissionList - organisme avec filtrage
2function MissionList() {
3 const { missions, filter, setFilter, updateStatus } = useMission();
4
5 return (
6 <div>
7 <FilterBar value={filter} onChange={setFilter} />
8 {missions.map(m => (
9 <MissionCard key={m.id} mission={m} onStatusChange={updateStatus} />
10 ))}
11 </div>
12 );
13}MissionList joue ici le rôle d'un composant container : il prend les données de useMission et les transmet vers le bas. FilterBar est une autre molécule à écrire.
Étape 4 : Assembler en template
Enfin, assemble le tout dans une mise en page par composition. En Atomic Design, ce niveau s'appelle le template : DashboardLayout avec ses slots, dans lesquels tu places les organismes. Rempli de vraies données, il devient une page, le cinquième et dernier niveau.
Critères d'évaluation
- Composition : une mise en page avec au moins 3 slots (header, sidebar, main)
- Container/Presentational : la séparation de la logique et de la vue dans au moins 2 composants
- Custom hooks : au moins 3 hooks personnels avec de la logique réutilisable (
useToggle,useFetch,useFilter) - Compound Components : un système d'onglets ou un accordéon avec un état partagé
- Atomic Design : une organisation visible en atomes, molécules, organismes
Mon conseil : construis de bas en haut et lance l'application après chaque étape. D'abord les hooks, avec leurs données affichées dans la console, puis les atomes, et enfin le panneau complet. Bonne chance, pilote ! Montre que tu sais concevoir une architecture React comme un véritable ingénieur spatial.
Retiens : une station orbitale n'est pas un seul gros module, mais une multitude de petits modules bien reliés.
Code de cette leçon : App.jsx
1import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
2
3// =============================================
4// HOOKS PERSONNALISÉS
5// =============================================
6function useToggle(initial = false) {
7 const [value, setValue] = useState(initial);
8 const toggle = useCallback(() => setValue(p => !p), []);
9 return [value, toggle];
10}
11
12function useMission() {
13 const [missions, setMissions] = useState([
14 { id: 1, name: 'Exploration d’Alpha Centauri', status: 'active', progress: 65 },
15 { id: 2, name: 'Extraction dans la ceinture d’astéroïdes', status: 'active', progress: 30 },
16 { id: 3, name: 'Sauvetage de l’équipe Bravo', status: 'completed', progress: 100 },
17 { id: 4, name: 'Scan de l’espace lointain', status: 'failed', progress: 12 },
18 { id: 5, name: 'Ravitaillement de la station', status: 'active', progress: 80 },
19 ]);
20 const [filter, setFilter] = useState('all');
21
22 const filteredMissions = useMemo(() => {
23 if (filter === 'all') return missions;
24 return missions.filter(m => m.status === filter);
25 }, [missions, filter]);
26
27 const updateStatus = useCallback((id, status) => {
28 setMissions(prev => prev.map(m =>
29 m.id === id ? { ...m, status, progress: status === 'completed' ? 100 : m.progress } : m
30 ));
31 }, []);
32
33 return { missions: filteredMissions, allMissions: missions, filter, setFilter, updateStatus };
34}
35
36// =============================================
37// COMPOSANT COMPOSÉ (COMPOUND COMPONENT) : Tabs
38// =============================================
39const TabsCtx = createContext();
40
41function Tabs({ children, defaultTab = 0 }) {
42 const [active, setActive] = useState(defaultTab);
43 return (
44 <TabsCtx.Provider value={{ active, setActive }}>
45 <div className="tabs">{children}</div>
46 </TabsCtx.Provider>
47 );
48}
49Tabs.TabList = function TabList({ children }) {
50 return <div className="tab-list" role="tablist">{children}</div>;
51};
52Tabs.Tab = function Tab({ index, children }) {
53 const { active, setActive } = useContext(TabsCtx);
54 const selected = active === index;
55 return (
56 <button role="tab" aria-selected={selected} className={`tab ${selected ? 'active' : ''}`} onClick={() => setActive(index)}>
57 {children}
58 </button>
59 );
60};
61Tabs.Panel = function TabPanel({ index, children }) {
62 const { active } = useContext(TabsCtx);
63 return active === index ? <div className="tab-panel" role="tabpanel">{children}</div> : null;
64};
65
66// =============================================
67// ATOMES
68// =============================================
69// Les valeurs des statuts restent des codes, l’équipage voit des libellés en français
70const statusLabels = { active: 'En cours', completed: 'Terminée', failed: 'Échouée' };
71const filterLabels = { all: 'Toutes', active: 'En cours', completed: 'Terminées', failed: 'Échouées' };
72
73function StatusBadge({ status }) {
74 const colors = { active: 'green', completed: 'blue', failed: 'red' };
75 return <span className={`badge badge-${colors[status] || 'gray'}`}>{statusLabels[status] || status}</span>;
76}
77
78function ProgressBar({ value, label }) {
79 const color = value >= 80 ? '#4caf50' : value >= 40 ? '#ff9800' : '#f44336';
80 return (
81 <div className="progress-bar" role="progressbar" aria-label={label} aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
82 <div className="progress-fill" style={{ width: `${value}%`, background: color }} />
83 </div>
84 );
85}
86
87// =============================================
88// MOLÉCULES
89// =============================================
90function MissionCard({ mission, onComplete, onCancel }) {
91 return (
92 <div className="mission-card">
93 <div className="mc-header">
94 <h4>{mission.name}</h4>
95 <StatusBadge status={mission.status} />
96 </div>
97 <ProgressBar value={mission.progress} label={mission.name} />
98 {mission.status === 'active' && (
99 <div className="mc-actions">
100 <button className="btn-sm btn-success" onClick={() => onComplete(mission.id)}>
101 Terminer
102 </button>
103 <button className="btn-sm btn-danger" onClick={() => onCancel(mission.id)}>
104 Annuler
105 </button>
106 </div>
107 )}
108 </div>
109 );
110}
111
112function CrewMember({ name, role }) {
113 return (
114 <div className="crew-member">
115 <div className="cm-avatar">{name[0]}</div>
116 <div>
117 <div className="cm-name">{name}</div>
118 <div className="cm-role">{role}</div>
119 </div>
120 </div>
121 );
122}
123
124// =============================================
125// ORGANISMES
126// =============================================
127function MissionList({ missions, onComplete, onCancel }) {
128 return (
129 <div className="mission-list">
130 {missions.length === 0 && <p className="empty-msg">Aucune mission ne correspond au filtre</p>}
131 {missions.map(m => (
132 <MissionCard key={m.id} mission={m} onComplete={onComplete} onCancel={onCancel} />
133 ))}
134 </div>
135 );
136}
137
138function CrewPanel() {
139 const crew = [
140 { name: 'Commandant Nova', role: 'Capitaine' },
141 { name: 'Dr Stellar', role: 'Médecin de bord' },
142 { name: 'Astro', role: 'Ingénieur' },
143 { name: 'Ra', role: 'Navigateur' },
144 ];
145 return (
146 <div className="crew-panel">
147 {crew.map(c => <CrewMember key={c.name} name={c.name} role={c.role} />)}
148 </div>
149 );
150}
151
152function SystemsOverview() {
153 const systems = [
154 { name: 'Moteurs', value: 95 },
155 { name: 'Boucliers', value: 72 },
156 { name: 'Survie', value: 100 },
157 { name: 'Communications', value: 88 },
158 ];
159 return (
160 <div className="systems-overview">
161 {systems.map(s => (
162 <div key={s.name} className="sys-row">
163 <span>{s.name}</span>
164 <ProgressBar value={s.value} label={s.name} />
165 <span className="sys-pct">{s.value}%</span>
166 </div>
167 ))}
168 </div>
169 );
170}
171
172// =============================================
173// TEMPLATE (Composition)
174// =============================================
175function DashboardLayout({ header, sidebar, children }) {
176 return (
177 <div className="dashboard-layout">
178 <header className="dl-header">{header}</header>
179 <div className="dl-body">
180 {sidebar && <aside className="dl-sidebar">{sidebar}</aside>}
181 <main className="dl-main">{children}</main>
182 </div>
183 </div>
184 );
185}
186
187// =============================================
188// PAGE
189// =============================================
190export default function App() {
191 const { missions, allMissions, filter, setFilter, updateStatus } = useMission();
192 const [showFilters, toggleFilters] = useToggle(true);
193
194 const filters = ['all', 'active', 'completed', 'failed'];
195
196 // Les statistiques viennent de la liste complète, donc le filtre ne change pas les totaux de l’en-tête
197 const stats = useMemo(() => ({
198 total: allMissions.length,
199 active: allMissions.filter(m => m.status === 'active').length,
200 done: allMissions.filter(m => m.status === 'completed').length,
201 }), [allMissions]);
202
203 return (
204 <DashboardLayout
205 header={
206 <div className="header-bar">
207 <h1>Centre de contrôle de mission</h1>
208 <div className="header-stats">
209 <span>Total : {stats.total}</span>
210 <span>En cours : {stats.active}</span>
211 <span>Terminées : {stats.done}</span>
212 <button className="toggle-btn" onClick={toggleFilters}>
213 {showFilters ? 'Masquer les filtres' : 'Afficher les filtres'}
214 </button>
215 </div>
216 </div>
217 }
218 sidebar={showFilters && (
219 <div>
220 <h3 className="sidebar-title">Filtre des missions</h3>
221 <div className="filter-group">
222 {filters.map(f => (
223 <button
224 key={f}
225 className={`filter-btn ${filter === f ? 'active' : ''}`}
226 aria-pressed={filter === f}
227 onClick={() => setFilter(f)}
228 >
229 {filterLabels[f]}
230 </button>
231 ))}
232 </div>
233 </div>
234 )}
235 >
236 <Tabs defaultTab={0}>
237 <Tabs.TabList>
238 <Tabs.Tab index={0}>Missions</Tabs.Tab>
239 <Tabs.Tab index={1}>Équipage</Tabs.Tab>
240 <Tabs.Tab index={2}>Systèmes</Tabs.Tab>
241 </Tabs.TabList>
242 <Tabs.Panel index={0}>
243 <MissionList
244 missions={missions}
245 onComplete={(id) => updateStatus(id, 'completed')}
246 onCancel={(id) => updateStatus(id, 'failed')}
247 />
248 </Tabs.Panel>
249 <Tabs.Panel index={1}>
250 <CrewPanel />
251 </Tabs.Panel>
252 <Tabs.Panel index={2}>
253 <SystemsOverview />
254 </Tabs.Panel>
255 </Tabs>
256 </DashboardLayout>
257 );
258}Tu as repéré une erreur dans cette leçon ?
Tâches pratiques dans le jeu
- Éditeur de code
Combine les patterns du module dans un seul tableau de bord. Le hook useMissionData : ___BLANK1___ est la liste des missions dont le statut est égal au filtre (quand le filtre vaut 'all', le hook renvoie toutes les missions). Le hook useToggle : ___BLANK2___ est une fonction de mise à jour qui inverse la valeur précédente (toggle est dans useCallback avec un tableau vide, il ne peut donc pas lire value du premier rendu). Le compound component Tabs : ___BLANK3___ est l'objet avec activeTab et setActiveTab que Tabs fournit via TabsContext, et ___BLANK4___ met aria-selected à true seulement pour l'onglet actif. Les atomes, la molécule, l'organisme et DashboardLayout avec ses slots sont prêts.