Cours JavaScript et React · Module 11: Récupération de données et API

Race conditions et AbortController

7 min de lecture
Dans cette leçon6

Imagine que ton vaisseau spatial envoie simultanément des requêtes à trois stations spatiales différentes. La première répond après 5 secondes, la deuxième après 2, la troisième après 8. Si tu ne contrôles pas l'ordre des réponses, tu pourrais afficher les données d'une requête obsolète ! C'est une race condition (situation de course), l'un des bugs les plus courants et les plus difficiles à détecter dans les applications React. Tout fonctionne en local parce que ton réseau est rapide, et ça casse chez l'utilisateur qui a un mauvais signal.

Qu'est-ce qu'une race condition ?

Une race condition dans la récupération de données se produit quand le résultat d'une requête plus ancienne écrase celui d'une plus récente, simplement parce qu'il est arrivé plus tard. Le composant ci-dessous a l'air innocent : il récupère une planète chaque fois que la prop planetId change :

1// PROBLÈME : Race Condition !
2function PlanetInfo({ planetId }) {
3  const [planet, setPlanet] = useState(null);
4
5  useEffect(() => {
6    // L'utilisateur clique vite : planetId = 1, puis 2, puis 3
7    // Requête 1 envoyée (dure 3s)
8    // Requête 2 envoyée (dure 1s) <- répond EN PREMIER !
9    // Requête 3 envoyée (dure 2s)
10    // Ordre des réponses : 2, 3, 1
11    // Résultat : on affiche la planète 1, car sa réponse est arrivée EN DERNIER !
12
13    async function load() {
14      const res = await fetch(
15        `https://swapi.dev/api/planets/${planetId}/`
16      );
17      const data = await res.json();
18      setPlanet(data); // Chaque réponse écrase l'état !
19    }
20    load();
21  }, [planetId]);
22
23  return <div>{planet?.name}</div>;
24}

Le code est syntaxiquement correct et ne lève aucune erreur, et pourtant il affiche la planète 1 alors que l'utilisateur a choisi la 3. Le problème n'est pas la requête elle-même, c'est que chaque réponse appelle setPlanet sans condition.

AbortController - annuler des requêtes

AbortController est une API intégrée au navigateur pour annuler des opérations asynchrones. Tu crées un contrôleur, tu passes son signal à fetch(), et l'appel à controller.abort() interrompt la requête. Ça fonctionne comme un bouton « Annuler la transmission » sur le panneau de communication. Un fetch() interrompu rejette sa Promise avec une erreur nommée AbortError :

1function PlanetInfo({ planetId }) {
2  const [planet, setPlanet] = useState(null);
3  const [loading, setLoading] = useState(true);
4
5  useEffect(() => {
6    // On crée un contrôleur pour CET effet
7    const controller = new AbortController();
8
9    async function loadPlanet() {
10      try {
11        setLoading(true);
12        const response = await fetch(
13          `https://swapi.dev/api/planets/${planetId}/`,
14          { signal: controller.signal } // On branche le signal
15        );
16        const data = await response.json();
17        setPlanet(data);
18      } catch (err) {
19        if (err.name === 'AbortError') {
20          // C'est une annulation normale - on ignore
21          console.log('Requête annulée');
22        } else {
23          console.error('Erreur :', err);
24        }
25      } finally {
26        setLoading(false);
27      }
28    }
29
30    loadPlanet();
31
32    // Cleanup : annule la requête quand :
33    // 1. planetId change (nouvel effet)
34    // 2. le composant est démonté
35    return () => controller.abort();
36  }, [planetId]);
37
38  return <div>{loading ? 'Chargement...' : planet?.name}</div>;
39}

Le plus important, c'est la dernière ligne de l'effet : la fonction retournée par useEffect est le cleanup. La logique de récupération n'a pas changé, on a seulement ajouté le signal et le nettoyage. Un petit détail reste : finally passe loading à false même pour une requête annulée, ce que nous corrigerons dans le dernier pattern.

Comment le cleanup empêche les race conditions

D'après la documentation de React, la fonction de cleanup s'exécute avant chaque nouvelle exécution de l'effet avec des dépendances modifiées, ainsi qu'au démontage du composant. Suivons cela étape par étape :

1// L'utilisateur clique : planète 1 -> planète 2 -> planète 3
2
3// Étape 1 : useEffect s'exécute avec planetId=1
4//   -> crée controller_1, envoie fetch_1
5
6// Étape 2 : planetId passe à 2
7//   -> React appelle le cleanup de l'Étape 1 : controller_1.abort()
8//   -> fetch_1 est ANNULÉ (AbortError)
9//   -> useEffect s'exécute avec planetId=2
10//   -> crée controller_2, envoie fetch_2
11
12// Étape 3 : planetId passe à 3
13//   -> React appelle le cleanup de l'Étape 2 : controller_2.abort()
14//   -> fetch_2 est ANNULÉ
15//   -> useEffect s'exécute avec planetId=3
16//   -> crée controller_3, envoie fetch_3
17
18// Résultat : seul fetch_3 se termine et met à jour l'état !

Chaque exécution de l'effet a son propre contrôleur, donc le cleanup annule exactement « sa » requête et ne touche jamais la nouvelle. En mode développement avec StrictMode, React exécute volontairement l'effet, le cleanup, puis l'effet à nouveau. Un cleanup bien écrit rend cela sans conséquence.

Stale closures - le piège des closures obsolètes

Un autre problème sournois est la stale closure : une fonction « se souvient » de la valeur d'une variable issue du rendu dans lequel elle a été créée. Une closure, c'est une fonction accompagnée des variables qu'elle voyait au moment de sa création. Ici, l'intervalle a été créé au premier rendu, donc il voit count égal à 0 pour toujours :

1// PROBLÈME : stale closure
2function Counter() {
3  const [count, setCount] = useState(0);
4
5  useEffect(() => {
6    const interval = setInterval(() => {
7      // Cette fonction a « capturé » count = 0
8      // Elle voit toujours 0, même après de nombreux rendus !
9      setCount(count + 1); // Met toujours 1 !
10    }, 1000);
11
12    return () => clearInterval(interval);
13  }, []); // Tableau vide = closure du premier rendu
14
15  return <div>{count}</div>;
16}

Le compteur s'arrête à 1 alors que l'intervalle tourne sans arrêt. La solution est la forme fonctionnelle du setter, qui reçoit la valeur actuelle de React au lieu de celle mémorisée :

1// SOLUTION : utiliser un callback dans setState
2function Counter() {
3  const [count, setCount] = useState(0);
4
5  useEffect(() => {
6    const interval = setInterval(() => {
7      // Le callback reçoit la valeur ACTUELLE prev
8      setCount(prev => prev + 1); // Toujours correct !
9    }, 1000);
10
11    return () => clearInterval(interval);
12  }, []);
13
14  return <div>{count}</div>;
15}

Le tableau de dépendances est toujours vide et l'intervalle n'est toujours créé qu'une fois. Une seule ligne a changé, et le compteur augmente maintenant correctement.

Le flag ignore - une alternative à AbortController

Un pattern plus simple, que la documentation de React montre aussi, est une simple variable booléenne. Le cleanup la passe à true, et avant d'écrire l'état, on vérifie si le résultat est toujours d'actualité :

1function SearchResults({ query }) {
2  const [results, setResults] = useState([]);
3
4  useEffect(() => {
5    let ignore = false; // Flag : le résultat est-il à jour ?
6
7    async function search() {
8      const response = await fetch(
9        `https://swapi.dev/api/people/?search=${query}`
10      );
11      const data = await response.json();
12
13      // Vérifier le flag AVANT de mettre à jour l'état
14      if (!ignore) {
15        setResults(data.results);
16      }
17    }
18
19    search();
20
21    // Cleanup : marquer le résultat comme obsolète
22    return () => {
23      ignore = true;
24    };
25  }, [query]);
26
27  return (
28    <ul>
29      {results.map(r => <li key={r.name}>{r.name}</li>)}
30    </ul>
31  );
32}

Le flag ignore n'annule pas la requête (elle consomme toujours des ressources réseau), mais il empêche la mise à jour de l'état avec des données obsolètes. Je te recommande AbortController quand les requêtes sont lourdes ou fréquentes, comme une recherche à chaque frappe, et le flag quand la simplicité compte.

Pattern : Fetch sûr avec protection complète

Combinons toutes les techniques. Comme cette fonction retourne des données et non du JSX, c'est un custom hook, donc selon les règles de React son nom commence par use :

1function useSafeFetch(endpoint) {
2  const [data, setData] = useState(null);
3  const [loading, setLoading] = useState(true);
4  const [error, setError] = useState(null);
5
6  useEffect(() => {
7    const controller = new AbortController();
8
9    async function fetchData() {
10      try {
11        setLoading(true);
12        setError(null);
13
14        const response = await fetch(endpoint, {
15          signal: controller.signal,
16        });
17
18        if (!response.ok) {
19          throw new Error(`HTTP ${response.status}`);
20        }
21
22        const result = await response.json();
23        // Si on arrive ici, la requête n'a pas été annulée
24        setData(result);
25      } catch (err) {
26        if (err.name !== 'AbortError') {
27          setError(err.message);
28        }
29        // AbortError = annulation normale, on ne définit pas d'erreur
30      } finally {
31        if (!controller.signal.aborted) {
32          setLoading(false);
33        }
34      }
35    }
36
37    fetchData();
38    return () => controller.abort();
39  }, [endpoint]);
40
41  return { data, loading, error };
42}

La nouveauté, c'est la condition dans finally : une requête annulée n'éteint plus le drapeau de chargement, puisque la nouvelle va prendre le relais. Un composant l'utilise en une ligne, const { data, loading, error } = useSafeFetch(url), et nous reviendrons à ce hook dans le projet final.

Retiens : sans AbortController et sans cleanup correct, tes données risquent d'arriver de la mauvaise galaxie.

Code de cette leçon : App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Simulation d'API avec un délai aléatoire (1-4s)
4function fakeFetch(url, options = {}) {
5  const delay = 1000 + Math.random() * 3000;
6  const id = url.split('/').filter(Boolean).pop();
7  return new Promise((resolve, reject) => {
8    const timer = setTimeout(() => {
9      resolve({
10        ok: true,
11        json: () => Promise.resolve({
12          id,
13          name: "Planète " + id,
14          climate: ["désertique", "tropical", "arctique", "tempéré"][id % 4],
15          population: Math.floor(Math.random() * 1000000),
16          diameter: Math.floor(5000 + Math.random() * 15000),
17        }),
18      });
19    }, delay);
20
21    // Gestion de l'AbortController
22    if (options.signal) {
23      options.signal.addEventListener('abort', () => {
24        clearTimeout(timer);
25        reject(new DOMException('Aborted', 'AbortError'));
26      });
27    }
28  });
29}
30
31function PlanetDetails({ planetId }) {
32  const [planet, setPlanet] = useState(null);
33  const [loading, setLoading] = useState(true);
34  const [log, setLog] = useState([]);
35
36  useEffect(() => {
37    const controller = new AbortController();
38    const fetchId = Date.now();
39
40    setLog(prev => [...prev, `[Fetch #${planetId}] Envoi de la requête...`]);
41
42    async function loadPlanet() {
43      try {
44        setLoading(true);
45        const response = await fakeFetch(
46          `/api/planets/${planetId}`,
47          { signal: controller.signal }
48        );
49        const data = await response.json();
50        setPlanet(data);
51        setLog(prev => [...prev, `[Fetch #${planetId}] SUCCÈS - affichage des données`]);
52      } catch (err) {
53        if (err.name === 'AbortError') {
54          setLog(prev => [...prev, `[Fetch #${planetId}] ANNULÉ (race condition prevented!)`]);
55        } else {
56          setLog(prev => [...prev, `[Fetch #${planetId}] ERREUR : ${err.message}`]);
57        }
58      } finally {
59        if (!controller.signal.aborted) {
60          setLoading(false);
61        }
62      }
63    }
64
65    loadPlanet();
66
67    return () => {
68      controller.abort();
69      setLog(prev => [...prev, `[Cleanup #${planetId}] controller.abort() appelé`]);
70    };
71  }, [planetId]);
72
73  return (
74    <div>
75      {loading ? (
76        <div className="loading"><div className="spinner"></div><p>Chargement de la planète #{planetId}...</p></div>
77      ) : planet ? (
78        <div className="planet-detail">
79          <h2>{planet.name}</h2>
80          <div className="info-grid">
81            <div className="info-item"><span className="label">Climat</span><span className="value">{planet.climate}</span></div>
82            <div className="info-item"><span className="label">Population</span><span className="value">{planet.population?.toLocaleString()}</span></div>
83            <div className="info-item"><span className="label">Diamètre</span><span className="value">{planet.diameter} km</span></div>
84          </div>
85        </div>
86      ) : null}
87      <div className="log-panel">
88        <h3>Log des requêtes (clique vite !) :</h3>
89        {log.slice(-8).map((entry, i) => (
90          <p key={i} className={"log-entry" + (entry.includes('ANNULÉ') ? " cancelled" : entry.includes('SUCCÈS') ? " success" : "")}>{entry}</p>
91        ))}
92      </div>
93    </div>
94  );
95}
96
97function App() {
98  const [selectedPlanet, setSelectedPlanet] = useState(1);
99
100  const planets = Array.from({ length: 8 }, (_, i) => ({
101    id: i + 1,
102    name: "Planète " + (i + 1),
103  }));
104
105  return (
106    <div className="app">
107      <h1>Démo Race Conditions</h1>
108      <p>Clique vite sur différentes planètes - observe les logs d'annulation :</p>
109      <div className="planet-buttons">
110        {planets.map(p => (
111          <button
112            key={p.id}
113            className={"planet-btn" + (selectedPlanet === p.id ? " active" : "")}
114            onClick={() => setSelectedPlanet(p.id)}
115          >
116            {p.name}
117          </button>
118        ))}
119      </div>
120      <PlanetDetails planetId={selectedPlanet} />
121    </div>
122  );
123}
124
125export 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. Qu'est-ce qu'une race condition dans le contexte du data fetching en React ?

  2. 2. Quand React appelle-t-il la fonction de cleanup (return) de useEffect ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri horizontal

    Ordonne la syntaxe pour renvoyer une fonction de cleanup avec controller.abort() :

  • Éditeur de code

    AbortController prévenant les race conditions

  • Éditeur de code

    Le flag ignore dans useEffect

  • Tri vertical

    Ordonne la séquence des événements lorsque l'utilisateur change rapidement planetId de 1 à 2 (avec AbortController) :

  • Clique dans l'ordre

    Clique sur les éléments dans l'ordre pour vérifier si l'erreur est une AbortError (annulation normale) :

Articles utiles