Kurs JavaScript i React · Moduł 5: useEffect i własne hooki
Zaawansowane efekty - race conditions i debouncing
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:
- AbortController - anuluj requesty zamiast ignorować odpowiedzi. Oszczędza transfer i zasoby.
- Race conditions - używaj AbortController lub flag cancel, aby zapobiec nadpisaniu nowszych danych starszymi.
- Stale closures - używaj functional updater (
setState(prev => ...)) lubuseRefw callbackach timerów. - Złożony cleanup - zamykaj wszystkie zasoby: fetch, SSE, timery, event listenery.
- 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 => 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. Który z poniższych zasobów wymaga cleanup function w useEffect?
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: