JavaScript and React course Β· Module 11: Data Fetching and APIs

Race Conditions and AbortController

7 min read
In this lesson6

Imagine your spaceship sending requests to three different space stations at the same time. The first responds after 5 seconds, the second after 2, the third after 8. If you don't control the order of the responses, you might display data from an outdated request! This is a race condition, one of the most common and hardest-to-catch bugs in React applications. It works locally because your network is fast, and breaks for the user on a weak signal.

What is a Race Condition?

A race condition in data fetching happens when the result of an older request overwrites the result of a newer one, simply because it arrived later. The component below looks innocent: it fetches a planet every time the planetId prop changes:

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); // Every response overwrites state!
19    }
20    load();
21  }, [planetId]);
22
23  return <div>{planet?.name}</div>;
24}

The code is syntactically correct and throws no error, yet it shows planet 1 even though the user picked 3. The problem isn't the request itself, it's that every response calls setPlanet unconditionally.

AbortController - Cancelling Requests

AbortController is a built-in browser API for cancelling asynchronous operations. You create a controller, pass its signal to fetch(), and calling controller.abort() stops the request. It works like a "Cancel transmission" button on the communication panel. An aborted fetch() rejects its Promise with an error named AbortError:

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 } // Connect the signal
15        );
16        const data = await response.json();
17        setPlanet(data);
18      } catch (err) {
19        if (err.name === 'AbortError') {
20          // This is normal cancellation - ignore
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 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 most important part is the last line of the effect: the function returned from useEffect is the cleanup. The fetching logic hasn't changed, we only added the signal and the cleanup. One small issue remains: finally sets loading to false for a cancelled request too, which we'll fix in the final pattern.

How Cleanup Prevents Race Conditions

According to the React docs, the cleanup function runs before every re-run of the effect with changed dependencies and when the component unmounts. Let's trace it step by step:

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!

Each run of the effect has its own controller, so the cleanup cancels exactly "its own" request and never touches the new one. In development mode with StrictMode, React deliberately runs the effect, the cleanup and the effect again. A well-written cleanup makes that harmless.

Stale Closures - The Outdated Closure Trap

Another sneaky problem is a stale closure: a function "remembers" a variable's value from the render in which it was created. A closure is a function together with the variables it saw when it was created. Here the interval was created on the first render, so it sees count equal to 0 forever:

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}

The counter stops at 1 even though the interval keeps running. The fix is the functional form of the setter, which receives the current value from React instead of the remembered one:

1// SOLUTION: use callback in setState
2function Counter() {
3  const [count, setCount] = useState(0);
4
5  useEffect(() => {
6    const interval = setInterval(() => {
7      // Callback receives the CURRENT prev value
8      setCount(prev => prev + 1); // Always correct!
9    }, 1000);
10
11    return () => clearInterval(interval);
12  }, []);
13
14  return <div>{count}</div>;
15}

The dependency array is still empty and the interval is still created only once. One line changed, and the counter now grows correctly.

The ignore Flag - An Alternative to AbortController

A simpler pattern, which the React docs also show, is a plain boolean variable. The cleanup sets it to true, and before writing state we check whether the result is still current:

1function SearchResults({ query }) {
2  const [results, setResults] = useState([]);
3
4  useEffect(() => {
5    let ignore = false; // Flag: is the result still current?
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 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 state from being updated with stale data. I recommend AbortController where requests are heavy or frequent, like a search on every keystroke, and the flag where simplicity matters.

Pattern: Safe Fetch with Full Protection

Let's combine all the techniques. Because this function returns data rather than JSX, it's a custom hook, so by React's rules its name starts with use:

1function useSafeFetch(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 got 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}

What's new is the condition in finally: a cancelled request no longer switches off the loading flag, because the new one is about to take over. A component uses it in one line, const { data, loading, error } = useSafeFetch(url), and we'll come back to this hook in the final project.

Remember: without AbortController and proper cleanup, your data may arrive from the wrong galaxy.

Code for this lesson: App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Simulation of an API with a random delay (1-4s)
4function fakeFetch(url, options = {}) {
5  const delay = 1000 + Math.random() * 3000;
6  const id = url.split('/').filter(Boolean).pop();
7  return new Promise((resolve, reject) => {
8    const timer = setTimeout(() => {
9      resolve({
10        ok: true,
11        json: () => Promise.resolve({
12          id,
13          name: "Planet " + id,
14          climate: ["desert", "tropical", "arctic", "temperate"][id % 4],
15          population: Math.floor(Math.random() * 1000000),
16          diameter: Math.floor(5000 + Math.random() * 15000),
17        }),
18      });
19    }, delay);
20
21    // AbortController handling
22    if (options.signal) {
23      options.signal.addEventListener('abort', () => {
24        clearTimeout(timer);
25        reject(new DOMException('Aborted', 'AbortError'));
26      });
27    }
28  });
29}
30
31function PlanetDetails({ planetId }) {
32  const [planet, setPlanet] = useState(null);
33  const [loading, setLoading] = useState(true);
34  const [log, setLog] = useState([]);
35
36  useEffect(() => {
37    const controller = new AbortController();
38    const fetchId = Date.now();
39
40    setLog(prev => [...prev, `[Fetch #${planetId}] Sending request...`]);
41
42    async function loadPlanet() {
43      try {
44        setLoading(true);
45        const response = await fakeFetch(
46          `/api/planets/${planetId}`,
47          { signal: controller.signal }
48        );
49        const data = await response.json();
50        setPlanet(data);
51        setLog(prev => [...prev, `[Fetch #${planetId}] SUCCESS - displaying data`]);
52      } catch (err) {
53        if (err.name === 'AbortError') {
54          setLog(prev => [...prev, `[Fetch #${planetId}] CANCELLED (race condition prevented!)`]);
55        } else {
56          setLog(prev => [...prev, `[Fetch #${planetId}] ERROR: ${err.message}`]);
57        }
58      } finally {
59        if (!controller.signal.aborted) {
60          setLoading(false);
61        }
62      }
63    }
64
65    loadPlanet();
66
67    return () => {
68      controller.abort();
69      setLog(prev => [...prev, `[Cleanup #${planetId}] controller.abort() called`]);
70    };
71  }, [planetId]);
72
73  return (
74    <div>
75      {loading ? (
76        <div className="loading"><div className="spinner"></div><p>Loading planet #{planetId}...</p></div>
77      ) : planet ? (
78        <div className="planet-detail">
79          <h2>{planet.name}</h2>
80          <div className="info-grid">
81            <div className="info-item"><span className="label">Climate</span><span className="value">{planet.climate}</span></div>
82            <div className="info-item"><span className="label">Population</span><span className="value">{planet.population?.toLocaleString()}</span></div>
83            <div className="info-item"><span className="label">Diameter</span><span className="value">{planet.diameter} km</span></div>
84          </div>
85        </div>
86      ) : null}
87      <div className="log-panel">
88        <h3>Request log (click fast!):</h3>
89        {log.slice(-8).map((entry, i) => (
90          <p key={i} className={"log-entry" + (entry.includes('CANCELLED') ? " cancelled" : entry.includes('SUCCESS') ? " success" : "")}>{entry}</p>
91        ))}
92      </div>
93    </div>
94  );
95}
96
97function App() {
98  const [selectedPlanet, setSelectedPlanet] = useState(1);
99
100  const planets = Array.from({ length: 8 }, (_, i) => ({
101    id: i + 1,
102    name: "Planet " + (i + 1),
103  }));
104
105  return (
106    <div className="app">
107      <h1>Race Conditions Demo</h1>
108      <p>Click different planets quickly - watch the cancellation logs:</p>
109      <div className="planet-buttons">
110        {planets.map(p => (
111          <button
112            key={p.id}
113            className={"planet-btn" + (selectedPlanet === p.id ? " active" : "")}
114            onClick={() => setSelectedPlanet(p.id)}
115          >
116            {p.name}
117          </button>
118        ))}
119      </div>
120      <PlanetDetails planetId={selectedPlanet} />
121    </div>
122  );
123}
124
125export 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. What is a race condition in the context of data fetching in React?

  2. 2. When does React call the cleanup function (return) from useEffect?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Horizontal ordering

    Arrange the syntax for returning a cleanup function with controller.abort():

  • Code editor

    AbortController preventing race conditions

  • Code editor

    Ignore flag in useEffect

  • Vertical ordering

    Arrange the order of events when the user quickly changes planetId from 1 to 2 (with AbortController):

  • Click in order

    Click the elements in order to check if the error is an AbortError (normal cancellation):

Useful articles