Skip to content
React rc effects 5 min read

Avoiding Race Conditions

A race condition happens when two or more async operations are in flight at once and you can’t control which one finishes first. In React this bites hardest inside effects that fetch data: a fast-changing prop kicks off several overlapping requests, and the one that resolves last wins — even if it’s the response for a value the user has already moved past. The fix isn’t to make the network faster; it’s to make your effect ignore responses it no longer cares about.

The problem: responses arriving out of order

Imagine a profile page that re-fetches whenever userId changes. The user clicks quickly through users 1, 2, then 3. Three requests are now racing. If the request for user 1 happens to come back after the request for user 3, your component will overwrite the correct data (user 3) with stale data (user 1).

import { useEffect, useState } from 'react';

// BUGGY: no protection against out-of-order responses
function Profile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
      .then((res) => res.json())
      .then((data) => setUser(data)); // whichever resolves last wins
  }, [userId]);

  return <h2>{user?.name ?? 'Loading…'}</h2>;
}

If responses arrive as 1, 3, 2 the screen ends on user 2’s data even though userId is 3:

Output:

request userId=1 sent
request userId=2 sent
request userId=3 sent
response userId=1 -> setUser(user 1)
response userId=3 -> setUser(user 3)
response userId=2 -> setUser(user 2)   // WRONG: UI now shows user 2

The component renders the wrong user. Nothing threw an error — the bug is purely about timing, which makes it intermittent and hard to reproduce.

The ignore-flag cleanup pattern

The simplest, dependency-free fix is a boolean flag scoped to each effect run. The effect closes over its own ignore variable, and the cleanup function flips it to true. Because React runs the previous cleanup before re-running the effect, any earlier request’s .then sees ignore === true and silently skips the state update.

import { useEffect, useState } from 'react';

function Profile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let ignore = false;

    async function load() {
      const res = await fetch(
        `https://jsonplaceholder.typicode.com/users/${userId}`
      );
      const data = await res.json();
      if (!ignore) {
        setUser(data); // only the latest run is allowed to write
      }
    }

    load();
    return () => {
      ignore = true; // stale runs are now no-ops
    };
  }, [userId]);

  return <h2>{user?.name ?? 'Loading…'}</h2>;
}

Each render gets a fresh ignore. When userId changes from 1 to 2, the cleanup for the userId=1 run sets that closure’s ignore to true, so when user 1’s response finally arrives it does nothing. Only the most recent, un-cleaned-up run can call setUser.

The flag doesn’t cancel the network request — the bytes still download. It just guarantees a stale response can never reach setState. That’s usually all you need for correctness.

Cancelling with AbortController

If you want to actually stop the in-flight request — to free a connection or stop a large download — use an AbortController. Pass its signal to fetch, then call controller.abort() in cleanup. An aborted fetch rejects with an AbortError, which you swallow.

import { useEffect, useState } from 'react';

function Profile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function load() {
      try {
        const res = await fetch(
          `https://jsonplaceholder.typicode.com/users/${userId}`,
          { signal: controller.signal }
        );
        setUser(await res.json());
      } catch (err) {
        if (err.name !== 'AbortError') throw err; // ignore intentional aborts
      }
    }

    load();
    return () => controller.abort();
  }, [userId]);

  return <h2>{user?.name ?? 'Loading…'}</h2>;
}

When abort() runs, the pending request is torn down and its promise rejects, so there’s no late setUser. This is the better choice for slow endpoints, search-as-you-type, or anything where the wasted bandwidth matters.

Ignore flag vs AbortController

AspectIgnore flagAbortController
Stops the network requestNoYes
Prevents stale setStateYesYes
Works with non-fetch async (timers, third-party SDKs)YesOnly if the API accepts a signal
Extra codeOne booleanController + AbortError handling
Best forQuick, cheap requestsLarge/slow requests, search inputs

Both rely on the same React mechanism — the cleanup function running before the next effect run — so the patterns are interchangeable in spirit. Many teams combine them: abort the request and keep state writes guarded.

Why this is a top effect bug

Race conditions rank among the most common production effect bugs precisely because they hide during development. On a fast local connection responses usually return in order, so the buggy version appears to work. In production — with variable latency, slow mobile networks, and impatient users clicking fast — out-of-order responses become routine. React 18 Strict Mode helps surface the issue by double-invoking effects in development, which exercises your cleanup path and reveals missing guards.

Best Practices

  • Treat every data-fetching effect as racy by default; add an ignore flag or AbortController before you ship it.
  • Use the ignore flag for simple cases and AbortController when you also want to cancel the underlying request.
  • Always swallow AbortError so an intentional cancellation doesn’t surface as an unhandled rejection.
  • Keep the guard scoped to the effect run — declare let ignore = false inside the effect, never in module or component scope.
  • Don’t setState after the cleanup has fired; let the guard short-circuit any late resolution.
  • For anything beyond trivial fetching, prefer a data library (TanStack Query, SWR, or a framework loader) that handles cancellation and dedup for you.
  • Lean on Strict Mode’s double-invoke in development as a free test that your cleanup actually prevents stale writes.
Last updated June 14, 2026
Was this helpful?