JavaScript and React course Β· Module 5: useEffect and Custom Hooks

Advanced Effects - Race Conditions and Debouncing

6 min read
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:

  1. AbortController - cancel requests instead of ignoring responses. Saves bandwidth and resources.
  2. Race conditions - use AbortController or cancel flags to prevent newer data from being overwritten by older data.
  3. Stale closures - use functional updater (setState(prev => ...)) or useRef in timer callbacks.
  4. Complex cleanup - close all resources: fetch, SSE, timers, event listeners.
  5. 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 =&gt; 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. 1. Which of the following resources requires a cleanup function in useEffect?

  2. 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:

Useful articles