Cours JavaScript et React · Module 5: useEffect et hooks personnalisés

Effets avancés - Race conditions et debouncing

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

  1. AbortController - annuler les requêtes au lieu d'ignorer les réponses. Économise la bande passante et les ressources.
  2. 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.
  3. Stale closures - utilise une fonction de mise à jour (setState(prev => ...)) ou useRef dans les callbacks de minuteries.
  4. Nettoyage complexe - ferme toutes les ressources : fetch, SSE, minuteries, écouteurs d'événements.
  5. 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 =&gt; 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. 1. Laquelle des ressources suivantes nécessite une fonction de nettoyage dans useEffect ?

  2. 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 :

Articles utiles