Cours JavaScript et React · Module 5: useEffect et hooks personnalisés
Effets avancés - Race conditions et debouncing
Dans cette leçon6
Dans le module précédent, tu as appris les bases de l'optimisation de useEffect : bien choisir les dépendances, utiliser des indicateurs isCurrent pour prévenir les race conditions, et le debouncing simple. Il est temps de passer aux techniques avancées - comme un véritable officier mécanicien sur un vaisseau spatial, tu dois connaître non seulement les procédures de base mais aussi les protocoles d'urgence pour les situations les plus complexes.
AbortController - annuler des requêtes
Un simple indicateur isCurrent a un inconvénient : la requête continue de s'exécuter en arrière-plan, consommant des ressources réseau. Lorsque l'utilisateur passe rapidement d'une page à l'autre, des dizaines de requêtes inutiles peuvent tourner en arrière-plan. C'est comme envoyer des signaux radio à des stations que tu as déjà dépassées - tu gaspilles la puissance de l'émetteur.
AbortController est un mécanisme intégré au navigateur qui te permet d'annuler réellement une requête HTTP, plutôt que de simplement ignorer son résultat.
1function PlanetDetails({ planetId }) {
2 const [planet, setPlanet] = useState(null);
3 const [loading, setLoading] = useState(true);
4 const [error, setError] = useState(null);
5
6 useEffect(() => {
7 // Création du controller pour annuler la requête
8 const abortController = new AbortController();
9
10 setLoading(true);
11 setError(null);
12
13 fetch(`/api/planets/${planetId}`, {
14 signal: abortController.signal // Passer le signal à fetch
15 })
16 .then(res => res.json())
17 .then(data => {
18 setPlanet(data);
19 setLoading(false);
20 })
21 .catch(err => {
22 // AbortError signifie que la requête a été annulée intentionnellement
23 if (err.name !== 'AbortError') {
24 setError(err.message);
25 setLoading(false);
26 }
27 // Si AbortError - nous ne faisons rien car le composant n'existe plus
28 });
29
30 // Nettoyage : annuler la requête lorsque planetId change ou que le composant est démonté
31 return () => abortController.abort();
32 }, [planetId]);
33
34 if (loading) return <p>Chargement des données de la planète...</p>;
35 if (error) return <p>Erreur : {error}</p>;
36 return <h2>{planet?.name}</h2>;
37}La différence clé : avec un indicateur isCurrent, la requête continue en arrière-plan et consomme de la bande passante. Avec AbortController, le navigateur interrompt réellement la connexion réseau. Pour de gros fichiers ou des connexions lentes, c'est une énorme économie.
Race conditions - analyse approfondie
Une race condition dans le contexte de React est une situation où le résultat d'une opération asynchrone arrive au composant au mauvais moment. Imagine : un astronaute clique sur la planète Mars, puis rapidement sur Jupiter. La requête pour Mars met 2 secondes, celle pour Jupiter 0,5 seconde. Jupiter revient en premier - nous affichons correctement Jupiter. Mais après 2 secondes, Mars revient et écrase les données de Jupiter !
1// PROBLÈME : Race condition sans protection
2function StarMap({ starId }) {
3 const [star, setStar] = useState(null);
4
5 useEffect(() => {
6 // Chaque changement de starId déclenche un nouveau fetch
7 // Mais le fetch précédent tourne encore en arrière-plan !
8 fetchStarData(starId).then(data => {
9 setStar(data); // Peut écraser des données plus récentes !
10 });
11 }, [starId]);
12
13 return <div>{star?.name}</div>;
14}
15
16// SOLUTION : AbortController élimine la race condition
17function StarMap({ starId }) {
18 const [star, setStar] = useState(null);
19
20 useEffect(() => {
21 const controller = new AbortController();
22
23 fetchStarData(starId, { signal: controller.signal })
24 .then(data => setStar(data))
25 .catch(err => {
26 if (err.name !== 'AbortError') console.error(err);
27 });
28
29 return () => controller.abort(); // Annuler la requête précédente
30 }, [starId]);
31
32 return <div>{star?.name}</div>;
33}Stale closures - le piège des valeurs obsolètes
Une stale closure (fermeture obsolète) est un bug subtil qui apparaît lorsqu'un callback à l'intérieur de useEffect « voit » une ancienne version d'une variable d'état. C'est comme lire le rapport de capteur d'hier en pensant que ce sont des données actuelles.
1// PROBLÈME : Stale closure
2function AsteroidCounter() {
3 const [count, setCount] = useState(0);
4
5 useEffect(() => {
6 const timer = setInterval(() => {
7 // Cette fonction « voit » count du moment où le timer a été créé = toujours 0 !
8 setCount(count + 1); // Définit toujours à 1 car count = 0
9 }, 1000);
10
11 return () => clearInterval(timer);
12 }, []); // Tableau vide = l'effet s'exécute une fois, count figé à 0
13
14 return <p>Astéroïdes : {count}</p>;
15}
16
17// SOLUTION 1 : Utiliser une fonction de mise à jour
18function AsteroidCounter() {
19 const [count, setCount] = useState(0);
20
21 useEffect(() => {
22 const timer = setInterval(() => {
23 // La fonction de mise à jour reçoit toujours la valeur actuelle !
24 setCount(prevCount => prevCount + 1);
25 }, 1000);
26
27 return () => clearInterval(timer);
28 }, []);
29
30 return <p>Astéroïdes : {count}</p>;
31}
32
33// SOLUTION 2 : Utiliser useRef pour les valeurs qui doivent être actuelles
34function TrajectoryCalculator() {
35 const [velocity, setVelocity] = useState(100);
36 const velocityRef = useRef(velocity);
37
38 // Synchroniser la ref avec la dernière valeur
39 useEffect(() => {
40 velocityRef.current = velocity;
41 }, [velocity]);
42
43 useEffect(() => {
44 const timer = setInterval(() => {
45 // ref.current a toujours la dernière valeur
46 console.log('Vitesse actuelle :', velocityRef.current);
47 }, 1000);
48
49 return () => clearInterval(timer);
50 }, []);
51
52 return <input value={velocity} onChange={e => setVelocity(Number(e.target.value))} />;
53}Règle : Si un callback dans setInterval ou setTimeout a besoin de la valeur d'état actuelle, utilise une fonction de mise à jour (setState(prev => ...)) ou useRef.
Modèles de nettoyage avancés
La fonction de nettoyage dans useEffect ne se limite pas à clearInterval. Dans les applications réelles, tu dois nettoyer de nombreux types de ressources simultanément.
1function LiveDashboard({ stationId }) {
2 const [data, setData] = useState(null);
3
4 useEffect(() => {
5 const controller = new AbortController();
6 let eventSource = null;
7 let reconnectTimer = null;
8
9 // 1. Récupération des données initiales
10 fetch(`/api/station/${stationId}`, { signal: controller.signal })
11 .then(res => res.json())
12 .then(setData)
13 .catch(err => {
14 if (err.name !== 'AbortError') console.error(err);
15 });
16
17 // 2. Server-Sent Events pour les mises à jour en direct
18 function connectSSE() {
19 eventSource = new EventSource(`/api/station/${stationId}/live`);
20 eventSource.onmessage = (event) => {
21 setData(JSON.parse(event.data));
22 };
23 eventSource.onerror = () => {
24 eventSource.close();
25 // Se reconnecter après 5 secondes
26 reconnectTimer = setTimeout(connectSSE, 5000);
27 };
28 }
29 connectSSE();
30
31 // 3. Nettoyage : fermer TOUTES les ressources
32 return () => {
33 controller.abort(); // Annuler le fetch
34 if (eventSource) eventSource.close(); // Fermer le SSE
35 if (reconnectTimer) clearTimeout(reconnectTimer); // Annuler la reconnexion
36 };
37 }, [stationId]);
38
39 return <div>{data?.status}</div>;
40}Debounce avec nettoyage - le bon modèle
Dans le module précédent, tu as appris le debouncing simple. Voyons maintenant un modèle qui gère correctement le nettoyage et évite les fuites de mémoire :
1function useDebounce(value, delay) {
2 const [debouncedValue, setDebouncedValue] = useState(value);
3
4 useEffect(() => {
5 // Définir une minuterie pour mettre à jour la valeur débouncée
6 const timer = setTimeout(() => {
7 setDebouncedValue(value);
8 }, delay);
9
10 // Nettoyage : annuler la minuterie si value change avant l'expiration du délai
11 return () => clearTimeout(timer);
12 }, [value, delay]);
13
14 return debouncedValue;
15}
16
17// Utilisation dans un composant de recherche
18function GalaxySearch() {
19 const [query, setQuery] = useState('');
20 const debouncedQuery = useDebounce(query, 400);
21 const [results, setResults] = useState([]);
22
23 // Cet effet ne s'exécute que 400 ms après le dernier caractère
24 useEffect(() => {
25 if (!debouncedQuery) {
26 setResults([]);
27 return;
28 }
29
30 const controller = new AbortController();
31
32 fetch(`/api/search?q=${debouncedQuery}`, { signal: controller.signal })
33 .then(res => res.json())
34 .then(setResults)
35 .catch(err => {
36 if (err.name !== 'AbortError') console.error(err);
37 });
38
39 return () => controller.abort();
40 }, [debouncedQuery]);
41
42 return (
43 <div>
44 <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Rechercher dans la galaxie..." />
45 <ul>
46 {results.map(r => <li key={r.id}>{r.name}</li>)}
47 </ul>
48 </div>
49 );
50}Remarque comment nous combinons trois modèles : custom hook (useDebounce), AbortController et nettoyage. C'est un modèle typique dans les applications React professionnelles.
Résumé
Techniques avancées de gestion des effets :
- AbortController - annuler les requêtes au lieu d'ignorer les réponses. Économise la bande passante et les ressources.
- Race conditions - utilise AbortController ou des indicateurs d'annulation pour empêcher que des données plus récentes soient écrasées par des données plus anciennes.
- Stale closures - utilise une fonction de mise à jour (
setState(prev => ...)) ouuseRefdans les callbacks de minuteries. - Nettoyage complexe - ferme toutes les ressources : fetch, SSE, minuteries, écouteurs d'événements.
- Debounce + AbortController - combine les deux modèles pour une recherche optimale.
Code de cette leçon : App.jsx
1import React, { useState, useEffect, useRef, useCallback } from 'react';
2
3// === Custom Hook: useDebounce ===
4function useDebounce(value, delay) {
5 const [debouncedValue, setDebouncedValue] = useState(value);
6
7 useEffect(() => {
8 const timer = setTimeout(() => setDebouncedValue(value), delay);
9 return () => clearTimeout(timer);
10 }, [value, delay]);
11
12 return debouncedValue;
13}
14
15// === Demo 1: AbortController ===
16function AbortControllerDemo() {
17 const [planetId, setPlanetId] = useState(1);
18 const [planet, setPlanet] = useState(null);
19 const [loading, setLoading] = useState(false);
20 const [log, setLog] = useState([]);
21
22 const addLog = useCallback((msg) => {
23 setLog(prev => [...prev.slice(-6), `[${new Date().toLocaleTimeString()}] ${msg}`]);
24 }, []);
25
26 useEffect(() => {
27 const controller = new AbortController();
28 setLoading(true);
29 addLog(`Fetch planète #${planetId} - START`);
30
31 // Simulation d'un fetch avec AbortController
32 const timer = setTimeout(() => {
33 if (!controller.signal.aborted) {
34 const planets = ['Mercure','Vénus','Terre','Mars','Jupiter','Saturne','Uranus','Neptune'];
35 setPlanet({ id: planetId, name: planets[(planetId - 1) % planets.length] });
36 setLoading(false);
37 addLog(`Fetch planète #${planetId} - OK`);
38 }
39 }, 800 + Math.random() * 1200);
40
41 return () => {
42 controller.abort();
43 clearTimeout(timer);
44 addLog(`Fetch planète #${planetId} - ANNULÉ`);
45 };
46 }, [planetId, addLog]);
47
48 return (
49 <div className="demo-card">
50 <h2>AbortController</h2>
51 <p className="info">Clique rapidement - vois l'annulation !</p>
52 <div className="controls-row">
53 {[1,2,3,4,5].map(id => (
54 <button key={id}
55 className={planetId === id ? 'active' : ''}
56 onClick={() => setPlanetId(id)}>
57 #{id}
58 </button>
59 ))}
60 </div>
61 <div className="result">
62 {loading ? 'Chargement...' : planet ? `Planète : ${planet.name}` : 'Choisis une planète'}
63 </div>
64 <div className="log">
65 {log.map((l, i) => (
66 <div key={i} className={l.includes('ANNULÉ') ? 'log-abort' : l.includes('OK') ? 'log-ok' : 'log-start'}>
67 {l}
68 </div>
69 ))}
70 </div>
71 </div>
72 );
73}
74
75// === Demo 2: Stale Closure ===
76function StaleClosureDemo() {
77 const [count, setCount] = useState(0);
78 const [badCount, setBadCount] = useState(0);
79 const [running, setRunning] = useState(false);
80
81 // BUG : stale closure - count figé à 0
82 useEffect(() => {
83 if (!running) return;
84 const timer = setInterval(() => {
85 setBadCount(badCount + 1); // Voit toujours badCount=0 !
86 }, 500);
87 return () => clearInterval(timer);
88 }, [running]); // badCount absent des dépendances = stale closure
89
90 // FIX: functional updater
91 useEffect(() => {
92 if (!running) return;
93 const timer = setInterval(() => {
94 setCount(prev => prev + 1); // Toujours la valeur actuelle !
95 }, 500);
96 return () => clearInterval(timer);
97 }, [running]);
98
99 const reset = () => {
100 setRunning(false);
101 setCount(0);
102 setBadCount(0);
103 };
104
105 return (
106 <div className="demo-card">
107 <h2>Stale Closure</h2>
108 <div className="comparison">
109 <div className="bad">
110 <h3>BUG: count + 1</h3>
111 <div className="big-number">{badCount}</div>
112 <p>Figé à 1 !</p>
113 </div>
114 <div className="good">
115 <h3>FIX: prev => prev + 1</h3>
116 <div className="big-number">{count}</div>
117 <p>Augmente correctement !</p>
118 </div>
119 </div>
120 <div className="controls-row">
121 <button onClick={() => setRunning(true)} disabled={running}>Start</button>
122 <button onClick={reset}>Reset</button>
123 </div>
124 </div>
125 );
126}
127
128// === Demo 3: Debounce + AbortController ===
129function DebouncedSearchDemo() {
130 const [query, setQuery] = useState('');
131 const debouncedQuery = useDebounce(query, 400);
132 const [results, setResults] = useState([]);
133 const [searchCount, setSearchCount] = useState(0);
134 const [keystrokeCount, setKeystrokeCount] = useState(0);
135
136 useEffect(() => {
137 if (!debouncedQuery) {
138 setResults([]);
139 return;
140 }
141
142 const controller = new AbortController();
143 setSearchCount(c => c + 1);
144
145 // Simulation d'une recherche
146 const stars = [
147 'Sirius', 'Bételgeuse', 'Rigel', 'Véga', 'Altaïr',
148 'Deneb', 'Polaris', 'Antarès', 'Aldébaran', 'Spica',
149 'Procyon', 'Achernar', 'Capella', 'Arcturus', 'Canopus'
150 ];
151
152 const timer = setTimeout(() => {
153 if (!controller.signal.aborted) {
154 const filtered = stars.filter(s =>
155 s.toLowerCase().includes(debouncedQuery.toLowerCase())
156 );
157 setResults(filtered);
158 }
159 }, 200);
160
161 return () => {
162 controller.abort();
163 clearTimeout(timer);
164 };
165 }, [debouncedQuery]);
166
167 const handleInput = (e) => {
168 setQuery(e.target.value);
169 setKeystrokeCount(c => c + 1);
170 };
171
172 return (
173 <div className="demo-card">
174 <h2>Debounce + Abort</h2>
175 <input
176 className="search-input"
177 placeholder="Rechercher une étoile (ex. Syr, Bet, Rig)..."
178 value={query}
179 onChange={handleInput}
180 />
181 <div className="stats-row">
182 <span>Touches : {keystrokeCount}</span>
183 <span>Requêtes : {searchCount}</span>
184 <span>Économisées : {Math.max(0, keystrokeCount - searchCount)}</span>
185 </div>
186 {results.length > 0 && (
187 <div className="results">
188 {results.map(r => <div key={r} className="result-item">{r}</div>)}
189 </div>
190 )}
191 {debouncedQuery && results.length === 0 && (
192 <p className="no-results">Aucun résultat pour "{debouncedQuery}"</p>
193 )}
194 </div>
195 );
196}
197
198function App() {
199 return (
200 <div className="app">
201 <h1>Effets avancés</h1>
202 <div className="demos">
203 <AbortControllerDemo />
204 <StaleClosureDemo />
205 <DebouncedSearchDemo />
206 </div>
207 </div>
208 );
209}
210
211export default App;Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Laquelle des ressources suivantes nécessite une fonction de nettoyage dans useEffect ?
2. Comment prévenir les race conditions lors de la récupération de données dans useEffect (ex. en changeant rapidement de page) ?
Voici 2 des 7 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Clique dans l'ordre
Ordonne la mise en place de l'écouteur d'événements :
- Tri vertical
Ordonne l'ordre d'exécution des hooks pendant le rendu :
- Clique dans l'ordre
Ordonne la fonction de nettoyage d'une minuterie dans useEffect :
- Tri vertical
Ordonne les étapes pour prévenir les fuites de mémoire dans useEffect - de l'identification à la résolution :
- Clique dans l'ordre
Ordonne le hook personnalisé :
- Clique dans l'ordre
Ordonne le modèle d'invocation async dans useEffect :
- Tri vertical
Ordonne les étapes pour gérer fetch avec AbortController dans useEffect :
- Tri vertical
Ordonne le cycle de vie de l'effet avec nettoyage lorsque les dépendances changent :
- Tri horizontal
Ordonne les éléments de la déclaration d'un hook personnalisé, de gauche à droite :
- Tri vertical
Ordonne le cycle de vie de l'effet dans useEffect :