JavaScript and React course Β· Module 5: useEffect and Custom Hooks
Advanced Effects - Race Conditions and Debouncing
In this lesson6
In the previous module, you learned the basics of useEffect optimization: properly choosing dependencies, using isCurrent flags to prevent race conditions, and simple debouncing. Time for advanced techniques - like a true engineering officer on a spaceship, you must know not only the basic procedures but also emergency protocols for the most complex situations.
AbortController - canceling requests
A simple isCurrent flag has a drawback: the request continues running in the background, consuming network resources. When the user quickly switches between pages, dozens of useless requests may be running in the background. It's like sending radio signals to stations you've already flown away from - you're wasting transmitter power.
AbortController is a built-in browser mechanism that allows you to actually cancel an HTTP request, rather than just ignoring its result.
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 // Creating controller to cancel request
8 const abortController = new AbortController();
9
10 setLoading(true);
11 setError(null);
12
13 fetch(`/api/planets/${planetId}`, {
14 signal: abortController.signal // Passing signal to fetch
15 })
16 .then(res => res.json())
17 .then(data => {
18 setPlanet(data);
19 setLoading(false);
20 })
21 .catch(err => {
22 // AbortError means the request was intentionally cancelled
23 if (err.name !== 'AbortError') {
24 setError(err.message);
25 setLoading(false);
26 }
27 // If AbortError - we do nothing because the component no longer exists
28 });
29
30 // Cleanup: cancel request when planetId changes or component unmounts
31 return () => abortController.abort();
32 }, [planetId]);
33
34 if (loading) return <p>Loading planet data...</p>;
35 if (error) return <p>Error: {error}</p>;
36 return <h2>{planet?.name}</h2>;
37}The key difference: with an isCurrent flag, the request continues in the background and consumes bandwidth. With AbortController, the browser actually interrupts the network connection. For large files or slow connections, this is a huge saving.
Race conditions - deeper analysis
A race condition in the React context is a situation where the result of an asynchronous operation arrives at the component at the wrong moment. Imagine: an astronaut clicks on planet Mars, then quickly on Jupiter. The request for Mars takes 2 seconds, the one for Jupiter 0.5 seconds. Jupiter returns first - we correctly display Jupiter. But after 2 seconds Mars comes back and overwrites Jupiter's data!
1// PROBLEM: Race condition without protection
2function StarMap({ starId }) {
3 const [star, setStar] = useState(null);
4
5 useEffect(() => {
6 // Every starId change triggers a new fetch
7 // But the previous fetch is still running in the background!
8 fetchStarData(starId).then(data => {
9 setStar(data); // May overwrite newer data!
10 });
11 }, [starId]);
12
13 return <div>{star?.name}</div>;
14}
15
16// SOLUTION: AbortController eliminates the race condition
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(); // Cancel previous request
30 }, [starId]);
31
32 return <div>{star?.name}</div>;
33}Stale closures - the trap of outdated values
A stale closure is a subtle bug that appears when a callback inside useEffect "sees" an old version of a state variable. It's like reading yesterday's sensor report thinking it's current data.
1// PROBLEM: Stale closure
2function AsteroidCounter() {
3 const [count, setCount] = useState(0);
4
5 useEffect(() => {
6 const timer = setInterval(() => {
7 // This function "sees" count from when the timer was created = always 0!
8 setCount(count + 1); // Always sets to 1 because count = 0
9 }, 1000);
10
11 return () => clearInterval(timer);
12 }, []); // Empty array = effect runs once, count closed over 0
13
14 return <p>Asteroids: {count}</p>;
15}
16
17// SOLUTION 1: Use functional updater
18function AsteroidCounter() {
19 const [count, setCount] = useState(0);
20
21 useEffect(() => {
22 const timer = setInterval(() => {
23 // Functional updater always receives the current value!
24 setCount(prevCount => prevCount + 1);
25 }, 1000);
26
27 return () => clearInterval(timer);
28 }, []);
29
30 return <p>Asteroids: {count}</p>;
31}
32
33// SOLUTION 2: Use useRef for values that must be current
34function TrajectoryCalculator() {
35 const [velocity, setVelocity] = useState(100);
36 const velocityRef = useRef(velocity);
37
38 // Synchronize ref with the latest value
39 useEffect(() => {
40 velocityRef.current = velocity;
41 }, [velocity]);
42
43 useEffect(() => {
44 const timer = setInterval(() => {
45 // ref.current always has the latest value
46 console.log('Current velocity:', velocityRef.current);
47 }, 1000);
48
49 return () => clearInterval(timer);
50 }, []);
51
52 return <input value={velocity} onChange={e => setVelocity(Number(e.target.value))} />;
53}Rule: If a callback in setInterval or setTimeout needs the current state value, use a functional updater (setState(prev => ...)) or useRef.
Advanced cleanup patterns
The cleanup function in useEffect is not just clearInterval. In real applications, you need to clean up many types of resources simultaneously.
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. Fetching initial data
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 for live updates
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 // Reconnect after 5 seconds
26 reconnectTimer = setTimeout(connectSSE, 5000);
27 };
28 }
29 connectSSE();
30
31 // 3. Cleanup: close ALL resources
32 return () => {
33 controller.abort(); // Cancel fetch
34 if (eventSource) eventSource.close(); // Close SSE
35 if (reconnectTimer) clearTimeout(reconnectTimer); // Cancel reconnect
36 };
37 }, [stationId]);
38
39 return <div>{data?.status}</div>;
40}Debounce with cleanup - the correct pattern
In the previous module you learned simple debouncing. Now let's see a pattern that properly handles cleanup and avoids memory leaks:
1function useDebounce(value, delay) {
2 const [debouncedValue, setDebouncedValue] = useState(value);
3
4 useEffect(() => {
5 // Set timer to update debounced value
6 const timer = setTimeout(() => {
7 setDebouncedValue(value);
8 }, delay);
9
10 // Cleanup: cancel timer if value changes before delay expires
11 return () => clearTimeout(timer);
12 }, [value, delay]);
13
14 return debouncedValue;
15}
16
17// Usage in a search component
18function GalaxySearch() {
19 const [query, setQuery] = useState('');
20 const debouncedQuery = useDebounce(query, 400);
21 const [results, setResults] = useState([]);
22
23 // This effect runs only after 400ms from the last character
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="Search the galaxy..." />
45 <ul>
46 {results.map(r => <li key={r.id}>{r.name}</li>)}
47 </ul>
48 </div>
49 );
50}Notice how we combine three patterns: custom hook (useDebounce), AbortController, and cleanup. This is a typical pattern in professional React applications.
Summary
Advanced effect management techniques:
- AbortController - cancel requests instead of ignoring responses. Saves bandwidth and resources.
- Race conditions - use AbortController or cancel flags to prevent newer data from being overwritten by older data.
- Stale closures - use functional updater (
setState(prev => ...)) oruseRefin timer callbacks. - Complex cleanup - close all resources: fetch, SSE, timers, event listeners.
- Debounce + AbortController - combine both patterns for optimal searching.
Code for this lesson: 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 planet #${planetId} - START`);
30
31 // Simulating fetch with AbortController
32 const timer = setTimeout(() => {
33 if (!controller.signal.aborted) {
34 const planets = ['Mercury','Venus','Earth','Mars','Jupiter','Saturn','Uranus','Neptune'];
35 setPlanet({ id: planetId, name: planets[(planetId - 1) % planets.length] });
36 setLoading(false);
37 addLog(`Fetch planet #${planetId} - OK`);
38 }
39 }, 800 + Math.random() * 1200);
40
41 return () => {
42 controller.abort();
43 clearTimeout(timer);
44 addLog(`Fetch planet #${planetId} - CANCELLED`);
45 };
46 }, [planetId, addLog]);
47
48 return (
49 <div className="demo-card">
50 <h2>AbortController</h2>
51 <p className="info">Click quickly - see the cancellation!</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 ? 'Loading...' : planet ? `Planet: ${planet.name}` : 'Choose a planet'}
63 </div>
64 <div className="log">
65 {log.map((l, i) => (
66 <div key={i} className={l.includes('CANCELLED') ? '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 locked at 0
82 useEffect(() => {
83 if (!running) return;
84 const timer = setInterval(() => {
85 setBadCount(badCount + 1); // Always sees badCount=0!
86 }, 500);
87 return () => clearInterval(timer);
88 }, [running]); // badCount not in dependencies = stale closure
89
90 // FIX: functional updater
91 useEffect(() => {
92 if (!running) return;
93 const timer = setInterval(() => {
94 setCount(prev => prev + 1); // Always the current value!
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>Frozen at 1!</p>
113 </div>
114 <div className="good">
115 <h3>FIX: prev => prev + 1</h3>
116 <div className="big-number">{count}</div>
117 <p>Increases correctly!</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 // Simulating a search
146 const stars = [
147 'Sirius', 'Betelgeuse', 'Rigel', 'Vega', 'Altair',
148 'Deneb', 'Polaris', 'Antares', 'Aldebaran', 'Spica',
149 'Procyon', 'Achernar', 'Capella', 'Arcturus', 'Canopus'
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="Search for a star (e.g. Sir, Bet, Rig)..."
178 value={query}
179 onChange={handleInput}
180 />
181 <div className="stats-row">
182 <span>Keystrokes: {keystrokeCount}</span>
183 <span>Requests: {searchCount}</span>
184 <span>Saved: {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">No results for "{debouncedQuery}"</p>
193 )}
194 </div>
195 );
196}
197
198function App() {
199 return (
200 <div className="app">
201 <h1>Advanced Effects</h1>
202 <div className="demos">
203 <AbortControllerDemo />
204 <StaleClosureDemo />
205 <DebouncedSearchDemo />
206 </div>
207 </div>
208 );
209}
210
211export default App;Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. Which of the following resources requires a cleanup function in useEffect?
2. How do you prevent race conditions when fetching data in useEffect (e.g., quickly switching between pages)?
These are 2 of 7 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Click in order
Arrange the event listener setup:
- Vertical ordering
Arrange the order of hook execution during rendering:
- Click in order
Arrange the cleanup function for a timer in useEffect:
- Vertical ordering
Order the steps for preventing memory leaks in useEffect - from identification to resolution:
- Click in order
Arrange the custom hook:
- Click in order
Arrange the async invocation pattern in useEffect:
- Vertical ordering
Arrange the steps for handling fetch with AbortController in useEffect:
- Vertical ordering
Order the effect lifecycle with cleanup when dependencies change:
- Horizontal ordering
Arrange the custom hook declaration elements from left to right:
- Vertical ordering
Order the effect lifecycle in useEffect: