Cours JavaScript et React · Module 8: Patterns React avancés
Projet principal : Construire une application de production
Dans cette leçon7
Tu connais déjà beaucoup d'outils séparés mais la vraie mission commence quand il faut les assembler en un seul vaisseau. Chaque pattern pris isolément paraît simple. Les ennuis commencent dans une grande application : où garder la logique, comment la partager entre les écrans et comment vérifier que rien n'est cassé après une modification. Cette leçon répond justement à ces questions.
Le moment culminant de notre expédition cosmique est arrivé. Tout au long de ce module, nous avons appris les patterns avancés et les bonnes pratiques de React - des custom hooks, en passant par les Higher-Order Components et les render props, jusqu'aux compound components, aux tests et au déploiement. Il est maintenant temps de combiner tous ces éléments en un seul projet cohérent : Centre de commande de mission spatiale.
Architecture du projet
Lors de la construction d'une application de production, il est crucial de bien penser son architecture. Notre projet combine les patterns que nous avons appris dans ce module. Commençons par le plan des ponts, c'est-à-dire la structure des dossiers :
1// Structure du projet
2// src/
3// hooks/ - Custom hooks (useMission, useFuel, useCrewStatus)
4// components/ - Composants réutilisables
5// hoc/ - Higher-Order Components (withAuth, withLogging)
6// context/ - React Contexts (MissionContext, ThemeContext)
7// utils/ - Fonctions utilitaires et validation
8// App.jsx - Composant principal de l'applicationChaque dossier est un pont distinct du vaisseau avec une mission clairement définie. React lui-même n'impose pas ce découpage, c'est notre convention. L'important, c'est que toute l'équipe s'en tienne à une seule.
Custom Hooks - le cœur de la logique métier
Les custom hooks te permettent d'extraire la logique des composants et de la rendre réutilisable. Dans notre projet, chaque aspect de la mission spatiale a son propre hook dédié. Le hook useMission garde les données de la mission et son statut dans l'état et expose des fonctions qui changent ce statut :
1// Custom hook pour la gestion de la mission
2function useMission(initialMission) {
3 const [mission, setMission] = useState(initialMission);
4 const [status, setStatus] = useState('planning');
5
6 const launch = () => {
7 if (mission.fuel >= mission.requiredFuel) {
8 setStatus('in-progress');
9 }
10 };
11
12 const complete = () => setStatus('completed');
13 const abort = () => setStatus('aborted');
14
15 return { mission, status, launch, complete, abort };
16}Un composant qui utilise useMission reçoit launch, complete et abort prêts à l'emploi, sans rien savoir du useState à l'intérieur. Le lancement ne change le statut que s'il y a au moins autant de carburant que la mission l'exige. Sinon, le statut reste planning.
Higher-Order Components et Render Props
Les HOC permettent d'ajouter des fonctionnalités aux composants sans modifier leur code source. Un Higher-Order Component est une fonction qui prend un composant et renvoie un nouveau composant avec des props supplémentaires. Ici, withLogging ajoute une fonction logAction :
1// HOC - ajoute la journalisation des actions
2function withLogging(WrappedComponent) {
3 return function LoggedComponent(props) {
4 const logAction = (action) => {
5 console.log(`[LOG] ${new Date().toISOString()}: ${action}`);
6 };
7 return <WrappedComponent {...props} logAction={logAction} />;
8 };
9}Le composant enveloppé fonctionne comme avant et reçoit seulement une nouvelle prop. Son propre code ne change pas.
Les render props offrent de la flexibilité dans le rendu. Un composant à render prop garde les données chez lui et confie ce qu'il faut afficher à la fonction passée dans la prop render :
1// Render Props - fournit les données de carburant
2function FuelProvider({ render }) {
3 const [fuel, setFuel] = useState(100);
4 const consume = (amount) => setFuel(prev => Math.max(0, prev - amount));
5 const refuel = (amount) => setFuel(prev => Math.min(100, prev + amount));
6 return render({ fuel, consume, refuel });
7}FuelProvider veille à ce que le carburant ne descende jamais sous 0 ni ne dépasse 100, mais il ne décide pas de l'apparence. Depuis l'arrivée des hooks, les custom hooks ont repris dans le code récent la plupart des tâches des HOC et des render props. Les deux patterns valent quand même la peine d'être connus, car tu les rencontreras dans des bibliothèques et des projets plus anciens.
Compound Components
Le pattern compound components permet de créer des composants qui collaborent comme un système cohérent. D'abord le parent, qui garde l'état d'ouverture partagé et le transmet à ses enfants :
1// Compound Component - Panneau de mission
2function MissionPanel({ children }) {
3 const [isExpanded, setIsExpanded] = useState(false);
4 return (
5 <div className="mission-panel">
6 {React.Children.map(children, child =>
7 React.isValidElement(child)
8 ? React.cloneElement(child, { isExpanded, setIsExpanded })
9 : child
10 )}
11 </div>
12 );
13}React.Children.map parcourt les enfants, et React.cloneElement ajoute à chacun les props isExpanded et setIsExpanded. Voici maintenant les parties du panneau, attachées comme propriétés de MissionPanel :
1MissionPanel.Header = function Header({ isExpanded, setIsExpanded, children }) {
2 return (
3 <div onClick={() => setIsExpanded(!isExpanded)} style={{ cursor: 'pointer' }}>
4 {children} {isExpanded ? '▲' : '▼'}
5 </div>
6 );
7};
8
9MissionPanel.Body = function Body({ isExpanded, children }) {
10 return isExpanded ? <div>{children}</div> : null;
11};L'en-tête bascule l'état, et le contenu n'apparaît qu'une fois le panneau ouvert. Une remarque pratique : react.dev range cloneElement parmi les API rarement utilisées, car elle casse facilement, par exemple si tu enveloppes un enfant dans un div supplémentaire. Dans du code récent, je te recommande de transmettre l'état partagé via un contexte (createContext et useContext) et de considérer cloneElement comme la version historique.
Tests - la clé de la fiabilité
Le code de production doit être testé. Chaque custom hook, composant et fonction utilitaire devrait avoir des tests appropriés. On teste les hooks avec la fonction renderHook et on enveloppe les appels qui modifient l'état dans act. Les deux fonctions viennent du paquet @testing-library/react (l'ancien paquet séparé @testing-library/react-hooks est déprécié) :
1// Exemple de test de custom hook
2// describe('useMission', () => {
3// it('should launch the mission when there is enough fuel', () => {
4// const { result } = renderHook(() =>
5// useMission({ fuel: 100, requiredFuel: 50 })
6// );
7// act(() => result.current.launch());
8// expect(result.current.status).toBe('in-progress');
9// });
10//
11// it('should not launch the mission without fuel', () => {
12// const { result } = renderHook(() =>
13// useMission({ fuel: 20, requiredFuel: 50 })
14// );
15// act(() => result.current.launch());
16// expect(result.current.status).toBe('planning');
17// });
18// });Le premier test vérifie qu'avec 100 unités de carburant la mission décolle, le second qu'avec 20 le statut reste planning. Nous avons exécuté les deux scénarios et ils donnent exactement ces résultats. Le code du hook n'a pas eu besoin de changer pour être testable.
Déploiement et optimisation
Avant de déployer l'application en production, assure-toi que :
- Optimisation des performances - utilise
React.memo,useMemoetuseCallbacklà où c'est nécessaire - Code splitting - découpe l'application en plus petits chunks avec
lazy()etSuspense - Variables d'environnement - stocke la configuration dans des fichiers
.env - Error boundaries - ajoute la gestion des erreurs au niveau des composants
- Build de production - lance
npm run buildet teste le résultat
Trois précisions sur cette liste. Dans un projet Vite, seules les variables préfixées par VITE_ arrivent dans le code du navigateur, lues via import.meta.env, donc n'y garde jamais de secrets. Une error boundary s'écrit toujours comme un composant de classe ou vient de la bibliothèque react-error-boundary. Et tu peux consulter le résultat du build en local avec la commande npm run preview.
Résumé du module
Dans ce module, tu as appris des patterns React avancés qui sont le fondement du développement d'applications professionnelles :
- Custom Hooks - extraire et partager la logique entre composants
- Higher-Order Components - ajouter des fonctionnalités par composition
- Render Props - rendu flexible avec passage de données
- Compound Components - créer des systèmes de composants cohérents
- Tests - garantir la fiabilité du code
- Déploiement - déployer des applications en production
Mon dernier conseil : commence par un custom hook, n'utilise les HOC et les render props que lorsqu'une bibliothèque l'exige, et écris un test pour chaque logique qui décide du sort de la mission. Après cette leçon, un projet pour ton mentor t'attend, dans lequel tu assembleras tous ces patterns en un seul laboratoire.
Ces patterns sont ta boîte à outils d'ingénieur cosmique - avec eux, tu peux construire des applications React au plus haut niveau !
Code de cette leçon : App.jsx
1import React, { useState, useCallback, memo } from 'react';
2
3// =============================================
4// CENTRE DE COMMANDE - MISSION SPATIALE
5// Projet combinant : custom hooks, HOC, render props,
6// compound components et les meilleures pratiques React
7// =============================================
8
9// --- Custom Hook : useMission ---
10function useMission(initialName = '') {
11 const [mission, setMission] = useState({
12 name: initialName,
13 status: 'planning',
14 fuel: 100,
15 crew: 0,
16 });
17
18 const launch = useCallback(() => {
19 setMission(prev => {
20 if (prev.fuel < 30) return prev;
21 return { ...prev, status: 'in-progress' };
22 });
23 }, []);
24
25 const complete = useCallback(() => {
26 setMission(prev => ({ ...prev, status: 'completed' }));
27 }, []);
28
29 const abort = useCallback(() => {
30 setMission(prev => ({ ...prev, status: 'aborted' }));
31 }, []);
32
33 const updateFuel = useCallback((amount) => {
34 setMission(prev => ({
35 ...prev,
36 fuel: Math.max(0, Math.min(100, prev.fuel + amount)),
37 }));
38 }, []);
39
40 const updateCrew = useCallback((count) => {
41 setMission(prev => ({ ...prev, crew: Math.max(0, count) }));
42 }, []);
43
44 const rename = useCallback((name) => {
45 setMission(prev => ({ ...prev, name }));
46 }, []);
47
48 return { mission, launch, complete, abort, updateFuel, updateCrew, rename };
49}
50
51// --- HOC : withStatusColor ---
52function withStatusColor(WrappedComponent) {
53 return function StatusColorComponent(props) {
54 const colorMap = {
55 planning: '#00d4ff',
56 'in-progress': '#ffd700',
57 completed: '#4CAF50',
58 aborted: '#f44336',
59 };
60 const statusColor = colorMap[props.status] || '#e0e1dd';
61 return <WrappedComponent {...props} statusColor={statusColor} />;
62 };
63}
64
65// --- Compound Component : MissionPanel ---
66function MissionPanel({ children }) {
67 const [isExpanded, setIsExpanded] = useState(true);
68 return (
69 <div style={{
70 background: '#1b2838', borderRadius: '12px',
71 padding: '16px', marginBottom: '12px',
72 border: '1px solid #2a3f5f',
73 }}>
74 {React.Children.map(children, child =>
75 React.isValidElement(child)
76 ? React.cloneElement(child, { isExpanded, setIsExpanded })
77 : child
78 )}
79 </div>
80 );
81}
82
83MissionPanel.Header = function Header({ isExpanded, setIsExpanded, children }) {
84 return (
85 <div
86 onClick={() => setIsExpanded(!isExpanded)}
87 style={{
88 cursor: 'pointer', display: 'flex',
89 justifyContent: 'space-between', alignItems: 'center',
90 color: '#ffd700', fontSize: '16px', fontWeight: 'bold',
91 }}
92 >
93 {children}
94 <span>{isExpanded ? '▲' : '▼'}</span>
95 </div>
96 );
97};
98
99MissionPanel.Body = function Body({ isExpanded, children }) {
100 if (!isExpanded) return null;
101 return <div style={{ marginTop: '12px' }}>{children}</div>;
102};
103
104// --- Render Props : FuelGauge ---
105function FuelGauge({ fuel, render }) {
106 const level = fuel > 60 ? 'high' : fuel > 30 ? 'medium' : 'low';
107 const color = level === 'high' ? '#4CAF50' : level === 'medium' ? '#ffd700' : '#f44336';
108 return render({ fuel, level, color });
109}
110
111// --- Composant avec HOC ---
112const StatusBadge = withStatusColor(memo(function StatusBadge({ status, statusColor }) {
113 return (
114 <span style={{
115 display: 'inline-block', padding: '4px 12px',
116 borderRadius: '12px', background: statusColor,
117 color: '#0d1b2a', fontWeight: 'bold', fontSize: '13px',
118 }}>
119 {status.toUpperCase()}
120 </span>
121 );
122}));
123
124// --- Composant principal App ---
125export default function App() {
126 const { mission, launch, complete, abort, updateFuel, updateCrew, rename } = useMission('Apollo X');
127
128 return (
129 <div style={{
130 background: '#0d1b2a', minHeight: '100vh',
131 padding: '20px', fontFamily: 'Arial', color: '#e0e1dd',
132 }}>
133 <h1 style={{ color: '#ffd700', textAlign: 'center', marginBottom: '6px' }}>
134 Centre de Commandement
135 </h1>
136 <p style={{ textAlign: 'center', color: '#778da9', marginBottom: '20px', fontSize: '14px' }}>
137 Projet combinant custom hooks, HOC, render props et compound components
138 </p>
139
140 {/* Panneau de mission - Compound Component */}
141 <MissionPanel>
142 <MissionPanel.Header>Données de la mission</MissionPanel.Header>
143 <MissionPanel.Body>
144 <div style={{ marginBottom: '10px' }}>
145 <label style={{ color: '#778da9', fontSize: '13px' }}>Nom de la mission :</label>
146 <input
147 value={mission.name}
148 onChange={e => rename(e.target.value)}
149 style={{
150 width: '100%', padding: '6px 10px', marginTop: '4px',
151 background: '#0d1b2a', border: '1px solid #2a3f5f',
152 borderRadius: '6px', color: '#e0e1dd', fontSize: '14px',
153 }}
154 />
155 </div>
156 <div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '10px' }}>
157 <span style={{ color: '#778da9', fontSize: '13px' }}>Status:</span>
158 <StatusBadge status={mission.status} />
159 </div>
160 <div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
161 <button onClick={launch} style={btnStyle('#4CAF50')}>Start</button>
162 <button onClick={complete} style={btnStyle('#2196F3')}>Terminer</button>
163 <button onClick={abort} style={btnStyle('#f44336')}>Interrompre</button>
164 </div>
165 </MissionPanel.Body>
166 </MissionPanel>
167
168 {/* Carburant - Render Props */}
169 <MissionPanel>
170 <MissionPanel.Header>Carburant</MissionPanel.Header>
171 <MissionPanel.Body>
172 <FuelGauge fuel={mission.fuel} render={({ fuel, color }) => (
173 <div>
174 <div style={{
175 width: '100%', height: '20px', background: '#0d1b2a',
176 borderRadius: '10px', overflow: 'hidden', marginBottom: '8px',
177 }}>
178 <div style={{
179 width: fuel + '%', height: '100%',
180 background: color, borderRadius: '10px',
181 transition: 'all 0.3s',
182 }} />
183 </div>
184 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
185 <span style={{ color, fontWeight: 'bold' }}>{fuel}%</span>
186 <div style={{ display: 'flex', gap: '6px' }}>
187 <button onClick={() => updateFuel(-10)} style={btnStyle('#f44336')}>-10</button>
188 <button onClick={() => updateFuel(10)} style={btnStyle('#4CAF50')}>+10</button>
189 </div>
190 </div>
191 </div>
192 )} />
193 </MissionPanel.Body>
194 </MissionPanel>
195
196 {/* Équipage */}
197 <MissionPanel>
198 <MissionPanel.Header>Équipage ({mission.crew} membres)</MissionPanel.Header>
199 <MissionPanel.Body>
200 <div style={{ display: 'flex', gap: '6px' }}>
201 <button onClick={() => updateCrew(mission.crew + 1)} style={btnStyle('#4CAF50')}>
202 + Ajouter
203 </button>
204 <button onClick={() => updateCrew(mission.crew - 1)} style={btnStyle('#f44336')}>
205 - Retirer
206 </button>
207 </div>
208 </MissionPanel.Body>
209 </MissionPanel>
210 </div>
211 );
212}
213
214function btnStyle(bg) {
215 return {
216 padding: '6px 14px', background: bg, color: 'white',
217 border: 'none', borderRadius: '6px', cursor: 'pointer',
218 fontWeight: 'bold', fontSize: '13px',
219 };
220}Tu as repéré une erreur dans cette leçon ?