AbortController: cancelling what you no longer need
The previous lesson's "Fix 2" — stop the stale request instead of discarding its result — has a name and a real mechanism behind it. This lesson covers what AbortController and AbortSignal actually are, why they're deliberately two separate objects, and the exact useEffect cleanup pattern that turns "the component unmounted" into "the network stops."
AbortController: cancelling what you no longer need
The previous lesson ended with two fixes for a race condition in async UI code. Fix 1 kept every stale response from landing by checking, when it arrived, whether it still mattered. Fix 2 was different in kind: don't wait for the stale response to arrive and then ignore it — stop the request from finishing at all. This lesson is Fix 2, in full.
The mechanism is AbortController, and the one thing to hold onto for the whole lesson is that aborting is a signal you send, not a switch you flip on the network itself. Nothing stops automatically. A fetch keeps running, a promise keeps waiting, a closure keeps sitting in memory — right up until something explicitly tells them to stop, and "something" almost always means a cleanup function you write yourself.
Here's the map: what AbortController and AbortSignal actually are as objects and why the API splits them in two, how that plugs into a useEffect that fetches data — specifically what React's cleanup timing guarantees you — the two concrete failure modes that show up when you skip it, and the complete pattern including the one common follow-up mistake of treating a deliberate abort as a real error.
Two objects, on purpose: the controller and the signal
new AbortController() gives you back an object with two things on it: a .signal property and an .abort() method. The signal is an AbortSignal — a small object with one meaningful piece of state, whether it's aborted or not, starting false. Calling .abort() on the controller does two things: it flips that signal's internal state to aborted, and it fires an abort event on the signal that anything listening can react to.
That's the entire mechanism. There's no magic cancellation happening inside the browser's networking stack on its own — AbortController is a plain, general-purpose "tell things to stop" primitive that predates and has nothing intrinsically to do with fetch. What makes it useful for cancelling a request is that fetch was written to cooperate with it: fetch(url, { signal }) checks that signal at the start of the call and, if it's already aborted, rejects immediately without sending anything. If the signal becomes aborted while the request is in flight, fetch notices, tears down the underlying network operation, and rejects the pending promise with an AbortError. Other browser APIs — addEventListener's options, some streaming APIs — accept the same signal and cooperate the same way, which is why this is a general cancellation primitive rather than a fetch-specific feature.
The split between controller and signal is a deliberate access-control decision, not an accident of API design. Whoever holds the controller can call .abort() — they have the actual power to cancel. Whoever only receives the .signal (which is the overwhelming majority of code: fetch, event listeners, any function you pass a signal into) can observe whether cancellation happened and can pass the signal further along, but has no way to trigger the abort themselves; there's no signal.abort() method to call. This mirrors the same reasoning as a const binding or a getter-only property: the code that creates the controller is asserting ownership over the decision to cancel, and everything downstream is trusted with visibility into that decision but not authority over it. When you write a useEffect that creates a controller and only ever hands out controller.signal to fetch, you are that owner, and the effect's cleanup function is the one place authorized to actually pull the trigger.
Wiring this into a data-fetching effect
Inside a useEffect that fetches data, the pattern is three parts: create the controller when the effect runs, pass its signal into fetch, and return a cleanup function that calls .abort() on that same controller.
useEffect(() => {
const controller = new AbortController();
fetch(`https://api.hisaab.measdev.me/groups/${groupId}`, {
signal: controller.signal,
})
.then((res) => res.json())
.then((data) => setGroup(data));
return () => {
controller.abort();
};
}, [groupId]);What matters here is exactly when React calls that returned function, because the whole pattern depends on it. React calls an effect's cleanup function in two situations, and only two: right before the effect runs again, because a dependency in its array changed (here, groupId), and when the component unmounts. In both cases, the cleanup that runs is the one returned by the previous run of the effect — the run whose fetch might still be in flight. So if groupId changes from 12 to 47 before the request for group 12 has resolved, React calls the old cleanup (aborting group 12's controller) and only then runs the effect again with the new groupId, creating a brand-new controller for group 47. If the component unmounts entirely while a request is outstanding, the same cleanup runs, and the request for whatever groupId was current gets aborted with nothing left to receive it.
What breaks without it: two distinct failure modes
Skip the cleanup — fetch inside the effect, never create a controller, never abort anything — and two separate problems follow, not one.
setState-after-unmount. The fetch that was in flight when the component unmounted doesn't know the component is gone; aborting is opt-in, and nothing opted in, so the request keeps running exactly as if nothing had happened. When it eventually resolves, its .then continuation runs and tries to call setGroup(data) — a state setter that belongs to a component instance that no longer exists in the tree. At best this is a silent no-op that wasted the network round trip for nothing. At worst it's a real bug: the data lands somewhere the user can no longer see it landing, timing becomes nondeterministic, and for years React printed a genuinely alarming console warning about calling a state update on an unmounted component specifically because this pattern was common enough to warn about by name.
A memory leak held open by the pending promise. Every .then callback is a closure, and a closure keeps everything it captured alive for as long as it might still run — every variable from that render's scope, every prop, every ref. As long as the fetch promise stays pending, that closure stays reachable, which means everything it captured stays reachable too, even though the component that would have used any of it is long gone and nothing in the UI needs a single byte of it. The browser has no way to know that; nobody told it the operation was pointless. The memory is held for exactly as long as the network takes to answer, which for a slow connection or an unresponsive server can be a long time, repeated on every navigation your users make away from a screen mid-fetch.
The complete pattern, including the abort-isn't-an-error trap
Aborting on cleanup is only half the fix. The other half is handling the rejection that abort causes correctly — because controller.abort() makes the pending fetch promise reject with an AbortError, and a naive catch block will treat that rejection exactly like a real network failure, showing the user an error message for a request that was cancelled on purpose and that they were never waiting on anyway.
function GroupScreen({ groupId }) {
const [group, setGroup] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setError(null);
fetch(`https://api.hisaab.measdev.me/groups/${groupId}`, {
signal: controller.signal,
})
.then((res) => res.json())
.then((data) => setGroup(data))
.catch((err) => {
if (err.name === "AbortError") {
// Expected: this fetch was cancelled on purpose. Not a real error.
return;
}
setError(err);
});
return () => {
controller.abort();
};
}, [groupId]);
if (error) return <ErrorBanner error={error} />;
if (!group) return <Spinner />;
return <GroupDetails group={group} />;
}The check is a single if, but it's the difference between an abort being invisible — which is exactly what you want, since the user did nothing wrong and the app is behaving correctly — and an abort surfacing as a spurious "something went wrong" banner every time someone navigates quickly between two screens that both fetch on mount.
This is worth naming plainly against the previous lesson: Fix 1 there discarded a stale result after the fact, by checking relevance when the response arrived. Fix 2 — this lesson — never lets the stale request finish doing its work in the first place. Both fixes solve the same race; this one is strictly more useful when the in-flight work is expensive or the component's lifetime is short relative to typical response times, because it stops paying for work nobody will use. The pattern above lives entirely inside a useEffect's cleanup function, and how to reason more generally about which side effects deserve a useEffect at all — as opposed to being handled some other way — is the subject of useEffect discipline: when it's the right tool.
Go deeper
- MDN — AbortController — The authoritative reference for the controller/signal split and exactly which built-in APIs (fetch and others) accept a signal.
- MDN — AbortSignal — The observer half of the pair — what a consumer that only receives the signal (not the controller) can and can't do with it.
- React docs — Synchronizing with Effects — The official pattern for cleanup functions and exactly when React calls them relative to dependency changes and unmounts, which this lesson's abort-on-cleanup code relies on.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- What two things does calling controller.abort() actually do to the associated AbortSignal?
- Why does the fetch API accept a signal instead of exposing its own cancel() method directly on the returned promise?
- Explain why AbortController and AbortSignal are two separate objects rather than one object with both abort() and an aborted state. What would break about that design?
- A useEffect depends on [groupId]. The user changes groupId twice in quick succession before either request resolves. Walk through exactly which cleanup functions run, in what order, and which controllers end up aborted.
- Name the two distinct failure modes that occur when a data-fetching useEffect has no cleanup function at all, and explain why they're genuinely separate problems rather than one bug described two ways.
- In the code example's catch block, what would happen to the user-visible UI if the AbortError check were removed?
- How does RTK Query's automatic abort-on-unmount behavior relate to the manual controller.abort() pattern in this lesson's useEffect example — is it doing something different, or the same thing on your behalf?