Kurs JavaScript i React · Moduł 11: Pobieranie danych i API
Race Conditions i AbortController
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. Co to jest race condition w kontekście pobierania danych w React?
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):