Imagine that your spaceship sends requests to three different space stations simultaneously. The first responds after 5 seconds, the second after 2, the third after 8. If you don't control the order of responses, you might display data from an outdated request! This is a race condition -- one of the most common and hardest-to-detect bugs in React applications.
A race condition in data fetching occurs when the result of an older request overwrites the result of a newer one:
1// PROBLEM: Race Condition!
2function PlanetInfo({ planetId }) {
3 const [planet, setPlanet] = useState(null);
4
5 useEffect(() => {
6 // User clicks quickly: planetId = 1, then 2, then 3
7 // Request 1 sent (takes 3s)
8 // Request 2 sent (takes 1s) <- responds FIRST!
9 // Request 3 sent (takes 2s)
10 // Response order: 2, 3, 1
11 // Result: we display planet 1, because its response came LAST!
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); // Each response overwrites state!
19 }
20 load();
21 }, [planetId]);
22
23 return <div>{planet?.name}</div>;
24}AbortController is a built-in browser mechanism for cancelling HTTP requests. It works like a "Cancel Transmission" button on the communication panel:1function PlanetInfo({ planetId }) {
2 const [planet, setPlanet] = useState(null);
3 const [loading, setLoading] = useState(true);
4
5 useEffect(() => {
6 // Create a controller for THIS effect
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 } // Attach the signal
15 );
16 const data = await response.json();
17 setPlanet(data);
18 } catch (err) {
19 if (err.name === 'AbortError') {
20 // This is a normal cancellation - ignore it
21 console.log('Request cancelled');
22 } else {
23 console.error('Error:', err);
24 }
25 } finally {
26 setLoading(false);
27 }
28 }
29
30 loadPlanet();
31
32 // Cleanup: cancel the request when:
33 // 1. planetId changes (new effect)
34 // 2. component unmounts
35 return () => controller.abort();
36 }, [planetId]);
37
38 return <div>{loading ? 'Loading...' : planet?.name}</div>;
39}The mechanism is simple but powerful:
1// User clicks: planet 1 -> planet 2 -> planet 3
2
3// Step 1: useEffect runs with planetId=1
4// -> creates controller_1, sends fetch_1
5
6// Step 2: planetId changes to 2
7// -> React calls cleanup from Step 1: controller_1.abort()
8// -> fetch_1 is CANCELLED (AbortError)
9// -> useEffect runs with planetId=2
10// -> creates controller_2, sends fetch_2
11
12// Step 3: planetId changes to 3
13// -> React calls cleanup from Step 2: controller_2.abort()
14// -> fetch_2 is CANCELLED
15// -> useEffect runs with planetId=3
16// -> creates controller_3, sends fetch_3
17
18// Result: only fetch_3 completes and updates state!Another tricky problem is the stale closure -- a function "remembers" an old value of a variable:
1// PROBLEM: stale closure
2function Counter() {
3 const [count, setCount] = useState(0);
4
5 useEffect(() => {
6 const interval = setInterval(() => {
7 // This function "closed over" count = 0
8 // Always sees 0, even after many renders!
9 setCount(count + 1); // Always sets 1!
10 }, 1000);
11
12 return () => clearInterval(interval);
13 }, []); // Empty array = closure from first render
14
15 return <div>{count}</div>;
16}
17
18// SOLUTION: use a callback in setState
19function Counter() {
20 const [count, setCount] = useState(0);
21
22 useEffect(() => {
23 const interval = setInterval(() => {
24 // Callback receives the CURRENT value of prev
25 setCount(prev => prev + 1); // Always correct!
26 }, 1000);
27
28 return () => clearInterval(interval);
29 }, []);
30
31 return <div>{count}</div>;
32}ignore Flag - An Alternative to AbortControllerA simpler pattern for preventing race conditions - a boolean flag:
1function SearchResults({ query }) {
2 const [results, setResults] = useState([]);
3
4 useEffect(() => {
5 let ignore = false; // Flag: is this result still relevant?
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 // Check the flag BEFORE updating state
14 if (!ignore) {
15 setResults(data.results);
16 }
17 }
18
19 search();
20
21 // Cleanup: mark the result as outdated
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}The
ignore flag doesn't cancel the request (it still uses network resources), but it prevents updating state with outdated data.Let's combine all techniques into a single safe pattern:
1function SafeDataFetcher({ 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 // If we reached here, the request was not cancelled
24 setData(result);
25 } catch (err) {
26 if (err.name !== 'AbortError') {
27 setError(err.message);
28 }
29 // AbortError = normal cancellation, don't set error
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}Race conditions are the silent enemy of every cosmic navigator. Without AbortController and proper cleanup, your data might be from the wrong galaxy!