Kurs JavaScript i React · Moduł 5: useEffect i własne hooki

Zaawansowane efekty - race conditions i debouncing

6 min czytania
W tej lekcji6

W poprzednim module nauczyłeś się podstaw optymalizacji useEffect: właściwego dobierania zależności, używania flag isCurrent do zapobiegania wyścigom i prostego debouncingu. Czas na zaawansowane techniki - jak prawdziwy oficer inżynieryjny na statku kosmicznym, musisz znać nie tylko podstawowe procedury, ale także protokoły awaryjne na wypadek najbardziej złożonych sytuacji.

AbortController - anulowanie requestów

Prosta flaga isCurrent ma wadę: request kontynuuje działanie w tle, zużywając zasoby sieciowe. Gdy użytkownik szybko przełącza się między stronami, w tle mogą działać dziesiątki bezużytecznych requestów. To jak wysyłanie sygnałów radiowych do stacji, z których już odleciałeś - marnujesz energię nadajnika.

AbortController to wbudowany mechanizm przeglądarki, który pozwala faktycznie anulować request HTTP, a nie tylko ignorować jego wynik.

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    // Tworzymy kontroler do anulowania requestu
8    const abortController = new AbortController();
9
10    setLoading(true);
11    setError(null);
12
13    fetch(`/api/planets/${planetId}`, {
14      signal: abortController.signal  // Przekazujemy sygnał do fetch
15    })
16      .then(res => res.json())
17      .then(data => {
18        setPlanet(data);
19        setLoading(false);
20      })
21      .catch(err => {
22        // AbortError oznacza, że request został celowo anulowany
23        if (err.name !== 'AbortError') {
24          setError(err.message);
25          setLoading(false);
26        }
27        // Jeśli AbortError - nie robimy nic, bo komponent już nie istnieje
28      });
29
30    // Cleanup: anuluj request gdy planetId się zmieni lub komponent się odmontuje
31    return () => abortController.abort();
32  }, [planetId]);
33
34  if (loading) return <p>Ładowanie danych planety...</p>;
35  if (error) return <p>Błąd: {error}</p>;
36  return <h2>{planet?.name}</h2>;
37}

Kluczowa różnica: z flagą isCurrent request leci dalej w tle i zużywa transfer. Z AbortController przeglądarka naprawdę przerywa połączenie sieciowe. Przy dużych plikach lub wolnym połączeniu to ogromna oszczędność.

Race conditions - głębsza analiza

Race condition (wyścig) w kontekście React to sytuacja, gdzie wynik asynchronicznej operacji trafia do komponentu w niewłaściwym momencie. Wyobraź sobie: astronauta klika na planetę Mars, potem szybko na Jowisza. Request o Marsa leci 2 sekundy, o Jowisza 0.5 sekundy. Jowisz wraca pierwszy - poprawnie wyświetlamy Jowisza. Ale po 2 sekundach wraca Mars i nadpisuje dane Jowisza!

1// PROBLEM: Race condition bez zabezpieczenia
2function StarMap({ starId }) {
3  const [star, setStar] = useState(null);
4
5  useEffect(() => {
6    // Każda zmiana starId uruchamia nowy fetch
7    // Ale poprzedni fetch nadal działa w tle!
8    fetchStarData(starId).then(data => {
9      setStar(data); // Może nadpisać nowsze dane!
10    });
11  }, [starId]);
12
13  return <div>{star?.name}</div>;
14}
15
16// ROZWIĄZANIE: AbortController eliminuje wyścig
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(); // Anuluj poprzedni request
30  }, [starId]);
31
32  return <div>{star?.name}</div>;
33}

Stale closures - pułapka nieaktualnych wartości

Stale closure (nieaktualne domknięcie) to subtelny bug, który pojawia się, gdy callback wewnątrz useEffect "widzi" starą wersję zmiennej stanu. To jak czytanie wczorajszego raportu sensorów, myśląc że to dane bieżące.

1// PROBLEM: Stale closure
2function AsteroidCounter() {
3  const [count, setCount] = useState(0);
4
5  useEffect(() => {
6    const timer = setInterval(() => {
7      // Ta funkcja "widzi" count z momentu utworzenia timera = zawsze 0!
8      setCount(count + 1); // Zawsze ustawia na 1, bo count = 0
9    }, 1000);
10
11    return () => clearInterval(timer);
12  }, []); // Pusta tablica = efekt uruchomiony raz, count zamknięty na 0
13
14  return <p>Asteroidy: {count}</p>;
15}
16
17// ROZWIĄZANIE 1: Użyj functional updater
18function AsteroidCounter() {
19  const [count, setCount] = useState(0);
20
21  useEffect(() => {
22    const timer = setInterval(() => {
23      // Functional updater zawsze otrzymuje aktualną wartość!
24      setCount(prevCount => prevCount + 1);
25    }, 1000);
26
27    return () => clearInterval(timer);
28  }, []);
29
30  return <p>Asteroidy: {count}</p>;
31}
32
33// ROZWIĄZANIE 2: Użyj useRef dla wartości, które muszą być aktualne
34function TrajectoryCalculator() {
35  const [velocity, setVelocity] = useState(100);
36  const velocityRef = useRef(velocity);
37
38  // Synchronizuj ref z najnowszą wartością
39  useEffect(() => {
40    velocityRef.current = velocity;
41  }, [velocity]);
42
43  useEffect(() => {
44    const timer = setInterval(() => {
45      // ref.current zawsze ma najnowszą wartość
46      console.log('Aktualna prędkość:', velocityRef.current);
47    }, 1000);
48
49    return () => clearInterval(timer);
50  }, []);
51
52  return <input value={velocity} onChange={e => setVelocity(Number(e.target.value))} />;
53}

Reguła: Jeśli callback w setInterval lub setTimeout potrzebuje aktualnej wartości stanu, użyj functional updater (setState(prev => ...)) lub useRef.

Zaawansowane wzorce cleanup

Cleanup function w useEffect to nie tylko clearInterval. W prawdziwych aplikacjach musisz czyścić wiele rodzajów zasobów jednocześnie.

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. Fetch danych początkowych
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 dla aktualizacji na żywo
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        // Ponowne połączenie po 5 sekundach
26        reconnectTimer = setTimeout(connectSSE, 5000);
27      };
28    }
29    connectSSE();
30
31    // 3. Cleanup: zamknij WSZYSTKIE zasoby
32    return () => {
33      controller.abort();              // Anuluj fetch
34      if (eventSource) eventSource.close();  // Zamknij SSE
35      if (reconnectTimer) clearTimeout(reconnectTimer); // Anuluj reconnect
36    };
37  }, [stationId]);
38
39  return <div>{data?.status}</div>;
40}

Debounce z cleanup - prawidłowy wzorzec

W poprzednim module poznałeś prosty debouncing. Teraz zobaczmy wzorzec, który poprawnie obsługuje cleanup i unika wycieków pamięci:

1function useDebounce(value, delay) {
2  const [debouncedValue, setDebouncedValue] = useState(value);
3
4  useEffect(() => {
5    // Ustaw timer na aktualizację zdebouncowanej wartości
6    const timer = setTimeout(() => {
7      setDebouncedValue(value);
8    }, delay);
9
10    // Cleanup: anuluj timer jeśli value zmieni się przed upływem delay
11    return () => clearTimeout(timer);
12  }, [value, delay]);
13
14  return debouncedValue;
15}
16
17// Użycie w komponencie wyszukiwarki
18function GalaxySearch() {
19  const [query, setQuery] = useState('');
20  const debouncedQuery = useDebounce(query, 400);
21  const [results, setResults] = useState([]);
22
23  // Ten efekt uruchamia się dopiero po 400ms od ostatniego znaku
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="Szukaj w galaktyce..." />
45      <ul>
46        {results.map(r => <li key={r.id}>{r.name}</li>)}
47      </ul>
48    </div>
49  );
50}

Zauważ, jak łączymy trzy wzorce: custom hook (useDebounce), AbortController i cleanup. To typowy wzorzec w profesjonalnych aplikacjach React.

Podsumowanie

Zaawansowane techniki zarządzania efektami:

  1. AbortController - anuluj requesty zamiast ignorować odpowiedzi. Oszczędza transfer i zasoby.
  2. Race conditions - używaj AbortController lub flag cancel, aby zapobiec nadpisaniu nowszych danych starszymi.
  3. Stale closures - używaj functional updater (setState(prev => ...)) lub useRef w callbackach timerów.
  4. Złożony cleanup - zamykaj wszystkie zasoby: fetch, SSE, timery, event listenery.
  5. Debounce + AbortController - łącz oba wzorce dla optymalnego wyszukiwania.
Kod do tej lekcji: 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 planety #${planetId} - START`);
30
31    // Symulacja fetch z AbortController
32    const timer = setTimeout(() => {
33      if (!controller.signal.aborted) {
34        const planets = ['Merkury','Wenus','Ziemia','Mars','Jowisz','Saturn','Uran','Neptun'];
35        setPlanet({ id: planetId, name: planets[(planetId - 1) % planets.length] });
36        setLoading(false);
37        addLog(`Fetch planety #${planetId} - OK`);
38      }
39    }, 800 + Math.random() * 1200);
40
41    return () => {
42      controller.abort();
43      clearTimeout(timer);
44      addLog(`Fetch planety #${planetId} - ANULOWANY`);
45    };
46  }, [planetId, addLog]);
47
48  return (
49    <div className="demo-card">
50      <h2>AbortController</h2>
51      <p className="info">Klikaj szybko - zobacz anulowanie!</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 ? 'Ładowanie...' : planet ? `Planeta: ${planet.name}` : 'Wybierz planetę'}
63      </div>
64      <div className="log">
65        {log.map((l, i) => (
66          <div key={i} className={l.includes('ANULOWANY') ? '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 zamknięty na 0
82  useEffect(() => {
83    if (!running) return;
84    const timer = setInterval(() => {
85      setBadCount(badCount + 1); // Zawsze widzi badCount=0!
86    }, 500);
87    return () => clearInterval(timer);
88  }, [running]); // badCount nie w zależnościach = stale closure
89
90  // FIX: functional updater
91  useEffect(() => {
92    if (!running) return;
93    const timer = setInterval(() => {
94      setCount(prev => prev + 1); // Zawsze aktualna wartość!
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>Zamrożony na 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>Rośnie poprawnie!</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    // Symulacja wyszukiwania
146    const stars = [
147      'Syriusz', 'Betelgeza', 'Rigel', 'Vega', 'Altair',
148      'Deneb', 'Polaris', 'Antares', 'Aldebaran', 'Spica',
149      'Procjon', 'Akhernar', 'Kapella', 'Arktur', 'Kanopus'
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="Szukaj gwiazdy (np. Syr, Bet, Rig)..."
178        value={query}
179        onChange={handleInput}
180      />
181      <div className="stats-row">
182        <span>Klawisze: {keystrokeCount}</span>
183        <span>Requesty: {searchCount}</span>
184        <span>Zaoszczędzone: {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">Brak wyników dla "{debouncedQuery}"</p>
193      )}
194    </div>
195  );
196}
197
198function App() {
199  return (
200    <div className="app">
201      <h1>Zaawansowane Efekty</h1>
202      <div className="demos">
203        <AbortControllerDemo />
204        <StaleClosureDemo />
205        <DebouncedSearchDemo />
206      </div>
207    </div>
208  );
209}
210
211export 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. Który z poniższych zasobów wymaga cleanup function w useEffect?

  2. 2. Jak zapobiec race conditions przy pobieraniu danych w useEffect (np. szybkie przełączanie między stronami)?

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

Zadania praktyczne w grze

  • Klikanie w kolejności

    Uporządkuj setup event listenera:

  • Układanie w pionie

    Ułóż kolejność wykonywania hooków podczas renderowania:

  • Klikanie w kolejności

    Ułóż cleanup function dla timera w useEffect:

  • Układanie w pionie

    Uporządkuj kroki zapobiegania wyciekom pamięci w useEffect - od identyfikacji do rozwiązania:

  • Klikanie w kolejności

    Uporządkuj custom hook:

  • Klikanie w kolejności

    Ułóż wzorzec wywołania async w useEffect:

  • Układanie w pionie

    Ułóż kroki obsługi fetch z AbortController w useEffect:

  • Układanie w pionie

    Uporządkuj cykl życia efektu z cleanup przy zmianie zależności:

  • Układanie w poziomie

    Ułóż elementy deklaracji custom hooka od lewej do prawej:

  • Układanie w pionie

    Uporządkuj cykl życia efektu w useEffect:

Przydatne artykuły