React Gives You the Loading State. It Does Not Give You Cancellation.

Aug 28, 2026
11 min read
React Gives You the Loading State. It Does Not Give You Cancellation.

The error handling article argued that failures are a product decision — what breaks, how loudly, and what the user does next. There's one failure mode it didn't cover, and it's the one that produces the weirdest bug reports. Not "the request failed." The opposite: the request succeeded, just too late, and nobody wanted the answer anymore.

React 19 made the front half of this much nicer. useActionState gives you the pending flag without a single useState:

const [result, submitSearch, isPending] = useActionState(searchPoliciesAction, null);

Three values, one line, and the loading state is handled. That used to be four lines of ceremony and at least one bug where setLoading(false) didn't run on the error path. Real improvement.

But look at what isPending actually tells you. It says React is still waiting on this action. It says nothing about the fetch you fired ninety milliseconds ago that's still in flight somewhere over the Atlantic. React flipped a boolean. The network doesn't know that happened.

A spinner that stops is a UI event. A request that stops is a network event. React only does the first one.

The bug that survives isPending

Type into a search box. Every keystroke fires a request. Responses come back whenever they feel like it — the one for "poli" leaves first, the one for "policy" leaves second and returns first, then "poli" lands and overwrites it. Now the input says policy and the list below shows results for poli.

The UI is not loading. The UI is not erroring. The UI is confidently wrong, and it stays wrong until the user touches something.

This is a race, and pending state cannot fix a race. isPending was true, then false, exactly as designed. What you needed was for the earlier request to stop existing the moment it stopped mattering. That's the job AbortController does, and it's the reason I think it deserves a chapter of its own rather than a footnote in a data-fetching post.

Honestly, I think the main reason this stays exotic is the name. AbortController sounds like something you'd find in an operating systems textbook. The actual API is three things.

The whole API, in about ten lines

const controller = new AbortController(); fetch('/api/policies', { signal: controller.signal }); controller.abort();

That's it. You make a controller, you hand its signal to whatever is doing the async work, and calling abort() tells that work to give up. The pending fetch rejects immediately.

Two details worth knowing up front, because they're where people trip.

First, a signal is single-use. Once a controller is aborted, it stays aborted forever — you don't reset it, you make a new one. One controller per request, not one per component.

Second, aborting rejects the promise. It doesn't quietly resolve to nothing. Which brings us to the mistake I'd bet is the single most common one in this whole area.

Cancellation is not a failure

When you abort a fetch, it throws. If you have a normal try/catch around it, that catch runs, and unless you say otherwise the user gets an error state for something you deliberately caused.

I've seen this produce red toasts on perfectly healthy apps. Navigate away from a page mid-load, and the app cheerfully informs you that something went wrong. Nothing went wrong. You left.

So the rule that matters more than any other in this article:

try { const response = await fetch(url, { signal }); return await response.json(); } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { return; // we cancelled this on purpose — not a user-facing failure } throw error; }

An abort is an instruction you gave. It should never reach your error UI, your toast system, or Sentry. Treat it as control flow, not as a fault.

There's a newer, cleaner way to express the same check if you already have the signal at hand:

if (signal.aborted) return;

Same idea, less instanceof archaeology. Both are fine. What's not fine is letting the abort fall through into the same branch as a 500.

The small version that covers most cases

Here's the shape I'd reach for by default. A ref holding the current controller, aborted right before the next request starts:

function PolicySearchPanel() { const inFlight = useRef<AbortController | null>(null); const [policies, searchPolicies, isPending] = useActionState( async (_previous: Policy[] | null, formData: FormData) => { inFlight.current?.abort(); const controller = new AbortController(); inFlight.current = controller; const query = String(formData.get('query') ?? ''); const response = await fetch( '/api/policies?query=' + encodeURIComponent(query), { signal: controller.signal }, ); return (await response.json()) as Policy[]; }, null, ); useEffect(() => () => inFlight.current?.abort(), []); return ( <form action={searchPolicies}> <input name="query" /> <button type="submit" disabled={isPending}>Search</button> </form> ); }

Two lines do the real work. inFlight.current?.abort() kills the previous request before starting a new one, so the stale response can never land. The useEffect cleanup aborts on unmount, so navigating away doesn't leave a request writing into a component that no longer exists.

That's the universal, boring version. It isn't clever and it doesn't need to be. Most cancellation bugs I can think of are covered by exactly those two moments: something newer started, and the thing that wanted this is gone.

Notice what useActionState is and isn't doing here. It owns isPending and the result value, which is genuinely less code than before. The cancellation is still entirely yours. The new hooks didn't make this obsolete — they made the missing half more visible, because now the only thing you're hand-rolling is the abort.

The signal has to reach the bottom

Here's where this stops being a component concern and becomes an architecture one.

Most apps don't call fetch in components. They call policiesApi.search(query), which calls a shared httpClient, which eventually calls fetch. If any layer in that chain doesn't take a signal, cancellation dies there — and you'll be sitting in the component wondering why abort does nothing.

So the API layer needs to pass it through, all the way down:

export async function searchPolicies( query: string, options?: { signal?: AbortSignal }, ): Promise<Policy[]> { const response = await httpClient.get( '/api/policies?query=' + encodeURIComponent(query), { signal: options?.signal }, ); return response.data; }

An optional signal on every async function that touches the network. It costs nothing at the call sites that don't care, and it's the difference between cancellation being available and being theoretically available.

This is also why I'd rather teach the primitive than the library helper. TanStack Query already hands your query function a signal — you just have to use it:

useQuery({ queryKey: ['policies', query], queryFn: ({ signal }) => searchPolicies(query, { signal }), });

That signal is there in every TanStack Query app in the world, and a lot of them pass it nowhere. The library already solved the plumbing. If your own layers drop the signal on the floor, the plumbing has nothing to plug into.

Two extras that are worth knowing

Timeouts, without the setTimeout dance:

fetch(url, { signal: AbortSignal.timeout(8000) });

And combining reasons to stop — say, "cancel if the user navigates away or if eight seconds pass":

const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]);

A timeout abort surfaces as a TimeoutError rather than an AbortError, which is exactly what you want: one of those deserves a message to the user, the other doesn't. That distinction is the whole game — why did this stop, and does the person staring at the screen need to know?

What to actually remember

Strip it down and there are maybe five things:

  • One controller per request. Never reuse an aborted one.
  • Abort the previous request before starting the next one.
  • Abort on unmount.
  • Never show an abort in the UI. It's control flow, not an error.
  • Accept an optional signal in every async function that reaches the network, or the chain breaks.

Nothing there is advanced. It's five habits, and once they're muscle memory you stop writing an entire category of bug — the stale-write, the ghost update, the error toast on a page you already left.

The part I keep coming back to

What strikes me about React 19's async story is how much it improved the visible half of the problem. Pending state used to be where the boilerplate lived, and now it mostly isn't. That's real, and I'd take it every time.

But it makes the asymmetry sharper. The framework got better at telling you it's waiting, and no better at stopping the thing it's waiting for — because it can't. React owns the component tree. It doesn't own the network. The moment you fire a request, you've created something that outlives React's opinion of it, and the only way to reel that back in is to have kept a handle on it.

AbortController is that handle. It's fifteen lines of very unglamorous code, and I'd argue it's the difference between an app that looks responsive and one that's actually consistent with what the user is doing right now.

If you've got a cancellation pattern that's held up better than the ref-and-cleanup version above — especially in bigger apps where requests fan out across several layers — I'd genuinely like to see it. Send it over.

Telegram

More than a blog post

I share frontend news and the reasoning behind it throughout the day. Pick the language that feels natural to you.

Need to discuss your project? Get in touch.