Most async code in frontend apps has a hidden bug: it doesn't stop when it should. A user navigates away mid-request. A component unmounts. A newer search query supersedes the previous one. The old network call keeps running, eventually resolves, and tries to update state that no longer exists. In React, that's the infamous warning: "Can't perform a React state update on an unmounted component." In vanilla JS, it silently delivers stale data.
AbortController is the browser's built-in solution. It's been in every major browser since 2018 — old enough that there's no excuse not to use it. But most tutorials skip it, most codebases use it inconsistently, and most devs reach for it only after they've debugged a flicker one too many times.
Here's the pattern, end to end.
The race condition you already have
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Result[]>([]);
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(r => r.json())
.then(data => setResults(data)); // runs even if query changed
}, [query]);
return <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>;
}
When the user types "re" and then "rea" before the first request finishes, two fetches are in flight simultaneously. The request for "re" might complete after the request for "rea" — and when it does, setResults silently overwrites the correct result with the stale one. The component shows the wrong data. No error, no warning, no clue.
This is a race condition, not a hypothetical. It happens on slow networks, during fast typing, on underpowered devices, and in staging environments right before a demo.
AbortController: the three-line fix
An AbortController is a pair: a controller object and a signal. You pass the signal into any abort-aware API; you call abort() to cancel it.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(r => r.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') throw err; // swallow cancellations
});
return () => controller.abort(); // runs when query changes or component unmounts
}, [query]);
When query changes, React calls the cleanup function before re-running the effect. controller.abort() fires immediately, and the previous fetch rejects with an AbortError before it can update state. The new fetch starts with a fresh controller. No race, no stale render.
The pattern has exactly three moving parts:
- Create a controller at the top of the effect.
- Pass
{ signal: controller.signal }tofetch. - Return
() => controller.abort()as the cleanup.
Everything else in the effect is unchanged.
The AbortError you must handle
When a fetch is cancelled, it rejects with an error whose name is "AbortError". This looks like a failure but isn't — it's an expected outcome. If you don't handle it, the rejection bubbles up to your error boundary or catch block and fires on every component unmount.
// With async/await — same rule applies
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const res = await fetch(`/api/search?q=${query}`, { signal: controller.signal });
const data = await res.json();
setResults(data);
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return;
setError('Something went wrong');
}
}
load();
return () => controller.abort();
}, [query]);
The guard is a one-liner and it must be there. Skipping it is the most common AbortController mistake.
Beyond fetch: cleaning up event listeners
AbortController isn't only for network requests. The addEventListener API accepts a signal option, and when that signal aborts, all listeners attached with it are removed automatically.
function useKeyboardShortcuts(handler: (e: KeyboardEvent) => void) {
useEffect(() => {
const controller = new AbortController();
const { signal } = controller;
window.addEventListener('keydown', handler, { signal });
window.addEventListener('keyup', handler, { signal });
window.addEventListener('keypress', handler, { signal });
return () => controller.abort(); // removes all three at once
}, [handler]);
}
Previously this meant storing each listener reference and calling removeEventListener on each one individually — three calls to clean up three listeners. With AbortController, one abort() removes them all. The cleanup code is shorter and harder to get wrong: you can't forget to remove a listener you added later if they all share the same signal.
When to reach for it
AbortController belongs in every useEffect that starts async work:
Data fetching with user-driven inputs — search fields, paginated lists, filters, anything where the effect re-runs with new dependencies.
Polling — when you start an interval or recursive timeout that should stop when the component unmounts.
Multiple related event listeners — set up once, torn down once, no mismatchedadd/removecalls.
Skip it for:
Intentional fire-and-forget mutations — a form submission or "send message" action where you want the request to complete even if the user navigates away. Cancelling a payment or a send mid-flight is worse than a stale update.
Server-side work that has real consequences — the abort is client-side only. Cancelling thefetchprevents your code from seeing the response; it doesn't stop the server from processing the request.
The dividing line: reads and subscriptions — abort them. Writes with side effects — let them complete.
The takeaway
AbortController doesn't require a library, a build plugin, or a new mental model. It's a constructor, a signal, and one method. The pattern fits in three lines of any useEffect that fetches data.
If you're running fetch inside useEffect without it, you have a race condition. The user has probably already seen it — a flicker, a stale result that snapped back after a moment, a warning in the console they couldn't explain. Now you can explain it, and you can fix it in the time it takes to write a cleanup function.
Every async operation that has a defined end-of-scope needs a way to cancel. The browser gives you that mechanism for free.