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.
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:
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:
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:
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:
// 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.
Keep reading