useEffect Cleanup and Race Conditions: Why Your Fetch Is Broken

Every effect that starts an async operation needs a cleanup function to cancel it. Without one, the component can set state after it unmounts, or an older request can overwrite a newer one. Here is the exact pattern that fixes it.

June 7, 20263 min read3 / 3

When a useEffect starts an async operation — a fetch, a timer, a subscription — and the component unmounts before that operation finishes, two things can go wrong.

First, the async callback completes and tries to call setState on a component that no longer exists. React shows a warning about this and, in some versions, the update simply does nothing — but the intent was wrong from the start.

Second, and more subtly: if the component re-mounts or the effect re-runs before the previous async operation finishes, you get a race condition. Two requests are in flight. Whichever completes last wins — even if it was the older, staler request.

A cleanup function prevents both.

How Cleanup Works

useEffect can return a function. React calls that function before the next effect execution and when the component unmounts:

TSX
useEffect(() => { // setup return () => { // cleanup }; }, [deps]);

The cleanup runs:

  • When the component unmounts
  • Before the effect re-runs due to dependency changes (cleans up the previous run first)

This is how you cancel async work when it is no longer needed.

Fixing a Fetch with AbortController

The standard pattern for cancellable fetch requests:

TSX
useEffect(() => { const controller = new AbortController(); fetch('/api/posts', { signal: controller.signal }) .then(res => res.json()) .then(data => setPosts(data)) .catch(err => { if (err.name === 'AbortError') return; // expected, not an error console.error(err); }); return () => controller.abort(); }, []);

When the cleanup function runs, controller.abort() cancels the in-flight request. The fetch promise rejects with an AbortError — which you catch and ignore since it was intentional. setPosts never gets called with stale data.

Fixing a Race Condition with a Flag

For async operations that cannot be aborted (older APIs, custom logic), an ignore flag achieves the same result:

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

When userId changes, the cleanup sets ignore = true on the previous run. The new run starts with its own ignore = false. If the old request completes after the new one started, ignore is already true and the stale data is discarded.

Cleaning Up Subscriptions and Timers

The same pattern applies to any effect that registers something over time:

TSX
// Timer cleanup useEffect(() => { const id = setInterval(() => tick(), 1000); return () => clearInterval(id); }, []); // Event listener cleanup useEffect(() => { window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []);

Without the cleanup, each time the effect runs (or the component mounts again, as in React Strict Mode), a new timer or listener stacks on top of the previous one. You end up with multiple intervals firing simultaneously or multiple listeners responding to the same event.

Every effect that sets something up should tear it down. Setup without cleanup is a memory leak waiting to happen.

Practice what you just read.

Race Conditions & CleanupFix the Race Condition
2 exercises