All Writing
7 min read

The Race Condition Hiding in Your useEffect

You fetch data inside a useEffect and it works in every test you run. Then someone clicks fast and sees the wrong data. This is the race condition sitting in almost every client side fetch, and the reason the cleanup function is not optional.

Fetching data inside useEffect is one of the first things you learn in React. It is also one of the easiest places to ship a bug that never shows up in review.

Here is the code almost everyone writes.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => setUser(data));
  }, [userId]);
 
  if (!user) return <Spinner />;
  return <Profile user={user} />;
}

It works. You test it. You switch between a few users. The right data shows up every time.

Then it ships. And someone clicks faster than your network responds.

The bug is about timing, not code

The effect runs after render. When userId changes, the effect runs again. That part is fine. That is what the dependency array is for.

The problem is the request you already started.

Say userId goes from 1 to 2 quickly. You now have two requests in flight. One for user 1. One for user 2.

Network requests do not resolve in the order you send them.

If the request for user 1 comes back after the request for user 2, this is what happens:

  1. The request for user 2 resolves. setUser(user2).
  2. The request for user 1 resolves. setUser(user1).

You are now looking at user 1's data on a page for user 2.

The last request to finish wins. Not the last one you asked for. That is the race condition.

It does not need fast typing to trigger. Any changing dependency does it. A route param. A search filter. A prop from a parent that re-renders. Anything that makes the effect run again while an old request is still open.

And it will pass every test where the requests happen to come back in order. Which is most of the time. Until it is not.

Why the effect cannot fix this on its own

People assume React cancels the old work when the effect re-runs. It does not.

React runs your cleanup function, then runs the effect again. If you did not write a cleanup function, there is nothing to cancel. The old request keeps going. The old .then still fires. The old setUser still runs.

React gives you the tool. You have to use it.

Fix one: ignore the stale response

The smallest correct fix is a flag.

useEffect(() => {
  let ignore = false;
 
  fetch(`/api/users/${userId}`)
    .then((res) => res.json())
    .then((data) => {
      if (!ignore) {
        setUser(data);
      }
    });
 
  return () => {
    ignore = true;
  };
}, [userId]);

Each run of the effect gets its own ignore variable. It lives in that run's closure.

When userId changes, React runs the cleanup for the previous run. That sets the old ignore to true. The old request can still finish, but its setUser is now skipped.

The fresh run has its own ignore that is still false. Its result is the one that lands.

You are not stopping the request. You are refusing to act on a result you no longer want.

This is exactly what the React docs mean when they tell you to clean up your effects. The cleanup is not ceremony. It is how you tell React that a run is stale.

The async version does not save you

Switching to async and await feels cleaner. It changes nothing about the race.

useEffect(() => {
  let ignore = false;
 
  async function load() {
    const res = await fetch(`/api/users/${userId}`);
    const data = await res.json();
    if (!ignore) {
      setUser(data);
    }
  }
 
  load();
 
  return () => {
    ignore = true;
  };
}, [userId]);

You cannot make the effect callback itself async. It has to return a cleanup function, not a promise. So you define an async function inside and call it. The ignore flag still does the real work.

Fix two: actually cancel the request

The flag fixes the state bug. It does not stop the network work. The old request still downloads a full response you throw away.

If you want to cancel the request itself, use AbortController.

useEffect(() => {
  const controller = new AbortController();
 
  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((res) => res.json())
    .then((data) => setUser(data))
    .catch((err) => {
      if (err.name !== "AbortError") {
        throw err;
      }
    });
 
  return () => {
    controller.abort();
  };
}, [userId]);

When the effect re-runs, controller.abort() cancels the in-flight request. fetch then rejects with an AbortError.

That is why the catch is there. You swallow the AbortError because you caused it on purpose. Any other error is real and should not be hidden.

This version saves bandwidth and stops work you do not need. On a slow connection or an expensive endpoint, that matters.

Which one to use

If you only care about showing the right data, the ignore flag is enough. It is simple and it is always correct for the state bug.

If you also want to stop wasted network work, use AbortController.

In a real app you probably reach for a data library like React Query or SWR before either of these. That is the honest answer. Those libraries exist partly because this bug is so common that people decided to solve it once and stop thinking about it. But you should still understand what they are doing for you. When something breaks, you are the one who has to know why.

One more thing. Server Components move a lot of fetching off the client, and that removes this problem for the data you load on the server. But the moment you fetch inside a client component, in an effect, based on something that changes, you are back here. This bug did not go away. It moved.

React already tries to warn you

In development, React StrictMode runs your effect twice on mount. Setup, cleanup, setup again.

People find this annoying. It is actually doing you a favor. If your effect breaks when it runs twice, it was going to break in production the first time a dependency changed. StrictMode just makes the failure show up on your machine instead of a user's.

If double invocation breaks your effect, that is the bug talking. Listen to it.

The rule worth remembering

If your effect starts something, it should be able to stop it.

A subscription. A timer. A request. If you kick it off in an effect and you do not return a cleanup, you are trusting that nothing will ever change before it finishes.

Sometimes that is a safe bet. Often it is not. And the times it is not are the bugs you cannot reproduce, because they depend on timing you do not control.

The cleanup function is the whole point. Not an afterthought.