Cours JavaScript et React · Module 8: Patterns React avancés

Projet principal : Construire une application de production

7 min de lecture
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'application

Chaque 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 :

  1. Optimisation des performances - utilise React.memo, useMemo et useCallback là où c'est nécessaire
  2. Code splitting - découpe l'application en plus petits chunks avec lazy() et Suspense
  3. Variables d'environnement - stocke la configuration dans des fichiers .env
  4. Error boundaries - ajoute la gestion des erreurs au niveau des composants
  5. Build de production - lance npm run build et 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 ?

Articles utiles