When you fire a fetch() and the component that triggered it unmounts, the request keeps going. The server still processes it. When the response arrives, it calls back into whatever JavaScript it finds — a stale closure, a dead state setter, a global store that has already moved on. React's "Can't perform a state update on an unmounted component" warning is the polite version of this. The silent version is worse: results from an old query overwriting the current UI.
These aren't mysterious race conditions. They're the predictable result of starting async work and never telling it to stop.
The race condition hiding in every search box
The search input is the clearest example. The user types "reac", your debounce fires a request. Before it lands, they finish typing "react" and you fire another. Two requests, in flight at the same time, and no guarantee about which one finishes first.
If the "reac" request happens to be slower — network jitter, a cache miss, a heavier result set — it will land after "react" and overwrite the correct results with the wrong ones. The bug reproduces maybe one time in twenty on a local dev server, and consistently in production on a slow connection.
The fix isn't smarter debouncing. It's cancelling the previous request when a new one starts.
AbortController in plain terms
AbortController is a browser-native API for cancelling async work. You create a controller, pass its signal to fetch(), and call controller.abort() to cancel. If the response hasn't arrived yet, the fetch promise rejects with an AbortError.
const controller = new AbortController();
fetch('/api/search?q=react', { signal: controller.signal })
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name === 'AbortError') return; // expected — not a real error
setError(err);
});
// Somewhere else, when we no longer need this request:
controller.abort();
Two things to internalize: signal is how the controller knows which request to cancel, and AbortError is intentional — catching and ignoring it is correct behavior, not a smell.
The React pattern: cancel in the cleanup function
useEffect's cleanup runs when the component unmounts and before the effect re-runs due to a dependency change. That makes it the natural place to cancel.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [query]);
When query changes, React runs the cleanup (aborting the in-flight request) before starting the next effect (firing a new one). The stale "reac" request is cancelled the moment "react" is typed — it will never call back into state.
When the component unmounts — navigation, modal close, conditional render — the cleanup fires and the pending request dies. No stale updates, no console warnings.
It's not just for fetch()
The signal property on an AbortController isn't coupled to fetch. Any API that accepts an AbortSignal can participate, and you can check signal.aborted in your own async loops:
async function processItems(items, signal) {
for (const item of items) {
if (signal.aborted) return; // bail out before the next iteration
await processOne(item);
}
}
const controller = new AbortController();
processItems(largeList, controller.signal);
// A cancel button, a timeout, a navigation — any of these can call:
controller.abort();
Check signal.aborted at each yield point. This pattern replaces global boolean flags and ad-hoc "is this still relevant?" tracking with a single, standard primitive that any caller can trigger.
For timeouts specifically, AbortSignal.timeout(ms) is cleaner than the manual approach:
// Auto-aborts after 5 seconds — no controller to hold, no setTimeout to clear
fetch('/api/slow-endpoint', { signal: AbortSignal.timeout(5000) })
.catch(err => {
if (err.name === 'TimeoutError') handleTimeout();
});
Note that timed-out requests throw TimeoutError, not AbortError — they're distinct cases, and it's useful to handle them differently.
What about React Query, SWR, or TanStack?
Data-fetching libraries handle this for you. React Query wires up an AbortController under the hood and cancels outgoing requests when a query key changes or a component unmounts. Understanding the pattern still matters: it's why the library behaves correctly, and you'll need the manual version any time you write custom fetch logic outside the library's boundaries.
The takeaway
Async code doesn't stop running because you stopped caring about it. A fetch() in flight is a real resource — a server-side computation, a response that will arrive and call something — and if nothing cancels it, it will complete against whatever state it finds.
AbortController is the correct primitive for this. The cost is three lines: create a controller, pass signal to fetch, return controller.abort from the cleanup. The return is eliminating an entire class of subtle, non-deterministic bugs.
If you've ever filed a race condition as "can't reliably reproduce," there's a good chance the unfiled fix is a missing controller.abort().
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 📸 Instagram — frontend best practices, daily: @bestpractice___
- 💼 LinkedIn — linkedin.com/in/parsa-jiravand
- ✉️ Email (work & contract inquiries): bestpractice2026@gmail.com
Top comments (0)