We use cookies to enhance your experience on the site
CodeWorlds

Race Conditions and AbortController

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.

What is a Race Condition?

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 - Cancelling Requests

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}

How Cleanup Prevents Race Conditions

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!

Stale Closures - The Closure Bug

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}

The
ignore
Flag - An Alternative to AbortController

A 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.

Pattern: Safe Fetch with Full Protection

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!

Go to CodeWorlds