Kurs JavaScript i React · Moduł 11: Pobieranie danych i API

Race Conditions i AbortController

6 min czytania
W tej lekcji6

Wyobraź sobie, że Twój statek kosmiczny wysyła zapytania do trzech różnych stacji kosmicznych jednocześnie. Pierwsza odpowiada po 5 sekundach, druga po 2, trzecia po 8. Jeśli nie kontrolujesz kolejności odpowiedzi, możesz wyświetlić dane z nieaktualnego zapytania! To jest race condition (wyścig), jeden z najczęstszych i najtrudniejszych do wychwycenia błędów w aplikacjach React. Działa lokalnie, bo Twoja sieć jest szybka, a psuje się u użytkownika na słabym zasięgu.

Czym jest Race Condition?

Race condition w pobieraniu danych występuje, gdy wynik starszego zapytania nadpisuje wynik nowszego, bo po prostu dotarł później. Komponent poniżej wygląda niewinnie: pobiera planetę za każdym razem, gdy zmieni się prop planetId:

1// PROBLEM: Race Condition!
2function PlanetInfo({ planetId }) {
3  const [planet, setPlanet] = useState(null);
4
5  useEffect(() => {
6    // Użytkownik klika szybko: planetId = 1, potem 2, potem 3
7    // Zapytanie 1 wysyłane (trwa 3s)
8    // Zapytanie 2 wysyłane (trwa 1s) <- odpowie PIERWSZE!
9    // Zapytanie 3 wysyłane (trwa 2s)
10    // Kolejność odpowiedzi: 2, 3, 1
11    // Wynik: wyświetlamy planetę 1, bo jej odpowiedź przyszła OSTATNIA!
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); // Każda odpowiedź nadpisuje stan!
19    }
20    load();
21  }, [planetId]);
22
23  return <div>{planet?.name}</div>;
24}

Kod jest poprawny składniowo i nie rzuca żadnego błędu, a mimo to pokazuje planetę 1, choć użytkownik wybrał 3. Problemem nie jest samo zapytanie, tylko to, że każda odpowiedź bezwarunkowo wywołuje setPlanet.

AbortController - anulowanie zapytań

AbortController to wbudowane API przeglądarki do anulowania operacji asynchronicznych. Tworzysz kontroler, przekazujesz jego signal do fetch(), a wywołanie controller.abort() przerywa zapytanie. Działa jak przycisk "Anuluj transmisję" na panelu komunikacyjnym. Przerwany fetch() odrzuca Promise z błędem o nazwie AbortError:

1function PlanetInfo({ planetId }) {
2  const [planet, setPlanet] = useState(null);
3  const [loading, setLoading] = useState(true);
4
5  useEffect(() => {
6    // Tworzymy kontroler dla TEGO efektu
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 } // Podłączamy sygnał
15        );
16        const data = await response.json();
17        setPlanet(data);
18      } catch (err) {
19        if (err.name === 'AbortError') {
20          // To normalne anulowanie - ignorujemy
21          console.log('Zapytanie anulowane');
22        } else {
23          console.error('Błąd:', err);
24        }
25      } finally {
26        setLoading(false);
27      }
28    }
29
30    loadPlanet();
31
32    // Cleanup: anuluj zapytanie gdy:
33    // 1. planetId się zmieni (nowy efekt)
34    // 2. komponent się odmontuje
35    return () => controller.abort();
36  }, [planetId]);
37
38  return <div>{loading ? 'Ładowanie...' : planet?.name}</div>;
39}

Najważniejsza jest ostatnia linia efektu: funkcja zwrócona z useEffect to cleanup. Logika pobierania się nie zmieniła, doszedł tylko sygnał i sprzątanie. Jeden drobiazg zostaje: finally ustawia loading na false także dla anulowanego zapytania, co poprawimy w ostatnim wzorcu.

Jak cleanup zapobiega race conditions

Według dokumentacji React funkcja cleanup uruchamia się przed każdym ponownym wykonaniem efektu ze zmienionymi zależnościami oraz przy odmontowaniu komponentu. Prześledźmy to krok po kroku:

1// Użytkownik klika: planeta 1 -> planeta 2 -> planeta 3
2
3// Krok 1: useEffect uruchamia się z planetId=1
4//   -> tworzy controller_1, wysyła fetch_1
5
6// Krok 2: planetId zmienia się na 2
7//   -> React wywołuje cleanup z Kroku 1: controller_1.abort()
8//   -> fetch_1 zostaje ANULOWANY (AbortError)
9//   -> useEffect uruchamia się z planetId=2
10//   -> tworzy controller_2, wysyła fetch_2
11
12// Krok 3: planetId zmienia się na 3
13//   -> React wywołuje cleanup z Kroku 2: controller_2.abort()
14//   -> fetch_2 zostaje ANULOWANY
15//   -> useEffect uruchamia się z planetId=3
16//   -> tworzy controller_3, wysyła fetch_3
17
18// Wynik: tylko fetch_3 się zakończy i zaktualizuje stan!

Każde uruchomienie efektu ma własny kontroler, więc cleanup anuluje dokładnie "swoje" zapytanie i nie dotyka nowego. W trybie deweloperskim ze StrictMode React celowo uruchamia efekt, sprzątanie i efekt jeszcze raz. Dobrze napisany cleanup sprawia, że nie ma to żadnych skutków ubocznych.

Stale closures - pułapka nieaktualnych domknięć

Inny podstępny problem to stale closure: funkcja "pamięta" wartość zmiennej z renderu, w którym powstała. Domknięcie (closure) to funkcja razem ze zmiennymi, które widziała w chwili utworzenia. Tu interwał powstał przy pierwszym renderze, więc na zawsze widzi count równe 0:

1// PROBLEM: stale closure
2function Counter() {
3  const [count, setCount] = useState(0);
4
5  useEffect(() => {
6    const interval = setInterval(() => {
7      // Ta funkcja "zamknęła" count = 0
8      // Zawsze widzi 0, nawet po wielu renderach!
9      setCount(count + 1); // Zawsze ustawia 1!
10    }, 1000);
11
12    return () => clearInterval(interval);
13  }, []); // Pusta tablica = closure z pierwszego rendera
14
15  return <div>{count}</div>;
16}

Licznik zatrzymuje się na 1, choć interwał działa bez przerwy. Rozwiązaniem jest forma funkcyjna settera, która dostaje aktualną wartość od Reacta zamiast tej zapamiętanej:

1// ROZWIĄZANIE: używaj callback w setState
2function Counter() {
3  const [count, setCount] = useState(0);
4
5  useEffect(() => {
6    const interval = setInterval(() => {
7      // Callback dostaje AKTUALNĄ wartość prev
8      setCount(prev => prev + 1); // Zawsze poprawne!
9    }, 1000);
10
11    return () => clearInterval(interval);
12  }, []);
13
14  return <div>{count}</div>;
15}

Tablica zależności wciąż jest pusta i interwał wciąż powstaje tylko raz. Zmieniła się jedna linia, a licznik rośnie poprawnie.

Flaga ignore - alternatywa dla AbortController

Prostszy wzorzec, który pokazuje też dokumentacja React, to zwykła zmienna logiczna. Cleanup ustawia ją na true, a przed zapisem stanu sprawdzamy, czy wynik jest nadal aktualny:

1function SearchResults({ query }) {
2  const [results, setResults] = useState([]);
3
4  useEffect(() => {
5    let ignore = false; // Flaga: czy wynik jest aktualny?
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      // Sprawdź flagę PRZED aktualizacją stanu
14      if (!ignore) {
15        setResults(data.results);
16      }
17    }
18
19    search();
20
21    // Cleanup: oznacz wynik jako nieaktualny
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}

Flaga ignore nie anuluje zapytania (nadal zużywa zasoby sieciowe), ale zapobiega aktualizacji stanu nieaktualnymi danymi. AbortController polecam tam, gdzie zapytania są ciężkie albo częste, jak wyszukiwarka przy każdym naciśnięciu klawisza, a flagę tam, gdzie liczy się prostota.

Wzorzec: bezpieczny fetch z pełną ochroną

Połączmy wszystkie techniki. Ponieważ ta funkcja zwraca dane, a nie JSX, jest custom hookiem, więc zgodnie z zasadami Reacta jej nazwa zaczyna się od 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        // Jeśli dotarliśmy tutaj, zapytanie nie zostało anulowane
24        setData(result);
25      } catch (err) {
26        if (err.name !== 'AbortError') {
27          setError(err.message);
28        }
29        // AbortError = normalne anulowanie, nie ustawiamy błędu
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}

Nowość to warunek w finally: anulowane zapytanie nie gasi już flagi ładowania, bo zaraz przejmie ją nowe. Komponent używa go jednym wierszem, const { data, loading, error } = useSafeFetch(url), a do tego hooka wrócimy w projekcie końcowym.

Pamiętaj: bez AbortControllera i prawidłowego cleanup Twoje dane mogą przylecieć z niewłaściwej galaktyki.

Kod do tej lekcji: App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Symulacja API z losowym opoznieniem (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: "Planeta " + id,
14          climate: ["pustynny", "tropikalny", "arktyczny", "umiarkowany"][id % 4],
15          population: Math.floor(Math.random() * 1000000),
16          diameter: Math.floor(5000 + Math.random() * 15000),
17        }),
18      });
19    }, delay);
20
21    // Obsluga 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}] Wysylam zapytanie...`]);
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}] SUKCES - wyswietlam dane`]);
52      } catch (err) {
53        if (err.name === 'AbortError') {
54          setLog(prev => [...prev, `[Fetch #${planetId}] ANULOWANO (race condition prevented!)`]);
55        } else {
56          setLog(prev => [...prev, `[Fetch #${planetId}] BLAD: ${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() wywolany`]);
70    };
71  }, [planetId]);
72
73  return (
74    <div>
75      {loading ? (
76        <div className="loading"><div className="spinner"></div><p>Ladowanie planety #{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">Klimat</span><span className="value">{planet.climate}</span></div>
82            <div className="info-item"><span className="label">Populacja</span><span className="value">{planet.population?.toLocaleString()}</span></div>
83            <div className="info-item"><span className="label">Srednica</span><span className="value">{planet.diameter} km</span></div>
84          </div>
85        </div>
86      ) : null}
87      <div className="log-panel">
88        <h3>Log zapytan (klikaj szybko!):</h3>
89        {log.slice(-8).map((entry, i) => (
90          <p key={i} className={"log-entry" + (entry.includes('ANULOWANO') ? " cancelled" : entry.includes('SUKCES') ? " 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: "Planeta " + (i + 1),
103  }));
104
105  return (
106    <div className="app">
107      <h1>Race Conditions Demo</h1>
108      <p>Klikaj szybko rozne planety - obserwuj logi anulowania:</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;

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Co to jest race condition w kontekście pobierania danych w React?

  2. 2. Kiedy React wywołuje funkcję cleanup (return) z useEffect?

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż składnię zwracania funkcji cleanup z controller.abort():

  • Edytor kodu

    AbortController zapobiegający race conditions

  • Edytor kodu

    Flaga ignore w useEffect

  • Układanie w pionie

    Ułóż kolejność zdarzeń gdy użytkownik szybko zmienia planetId z 1 na 2 (z AbortController):

  • Klikanie w kolejności

    Kliknij elementy w kolejności sprawdzenia, czy błąd to AbortError (normalne anulowanie):

Przydatne artykuły