skip to content

A React details panel refetches in a useEffect keyed on selectedId and calls controller.abort() in the effect's cleanup. Clicking quickly through the list now flashes a 'Request failed' error banner. What is happening, and how do you fix it?

level: seniorimportance: should knowfreq 42%

answer

  1. cancellation is not a failure
  2. look at what the catch branch writes
  3. the rejection names itself
  4. guard before any state write, finally included

basics

~20 s

Aborting rejects the in-flight fetch promise, and the effect's catch branch sets error state without checking why it rejected, so a deliberate cancellation renders as a failure. Guard the catch: when the rejection is an abort, return without writing any state.

solid answer

~50 s

Aborting a fetch rejects its promise, so the effect's `catch` runs — and if that branch calls `setError(...)` unconditionally, cancelling the previous request paints a failure the user never had. The component is still mounted here, since only `selectedId` changed, so the write lands and the banner renders until the next request resolves and clears it. The fix is to treat cancellation as a non-event: inspect the rejection before writing anything, `if (err.name === 'AbortError') return;`, and leave both the error and the loading state untouched. The rule underneath is worth saying out loud in an interview — a run that is being torn down must not write state. Its cleanup already fired, the next run owns the UI now, and anything the dying run sets is at best noise and at worst a value that outlives its own request.

code

javascript · 33 lines
javascript
import { useEffect, useState } from 'react';

export function useDetails(selectedId) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const controller = new AbortController();
    setError(null);
    setLoading(true);

    async function load() {
      try {
        const res = await fetch(`/api/items/${selectedId}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        setData(await res.json());
        setLoading(false);
      } catch (err) {
        if (err.name === 'AbortError') return;
        setError(err);
        setLoading(false);
      }
    }

    load();
    return () => controller.abort();
  }, [selectedId]);

  return { data, error, loading };
}

go deeper

for a junior

Recall that aborting rejects the fetch promise rather than making it disappear, so the catch block runs even when nothing went wrong.

for a middle

Explain why the write actually lands — the component is still mounted, only a dependency changed — and write the guard, including the loading flag that a finally block clears by mistake.

for a senior

Diagnose it from the symptom alone: a banner that appears only under fast interaction points at teardown-triggered state, and the fix is a narrow cancellation guard rather than swallowing every error.

for a principal

Own the invariant rather than the patch — a dead effect run must never write state — and decide whether app code enforces it by hand or the data layer makes cancelled requests unobservable to product code.

## The symptom and where it comes from The panel fetches on `selectedId`, and cleanup aborts. Click item A, then quickly item B: React tears down the A run, cleanup aborts A's request, then the B run starts. Somewhere in that sequence a red banner flashes. Nothing on the network actually failed — the only failure in the system is one you caused on purpose. The chain is short. `controller.abort()` does not make the pending promise disappear; it **rejects** it. That rejection travels into whatever handler the effect attached, and the usual effect body looks like this: ```js fetch(url, { signal: controller.signal }) .then((res) => res.json()) .then(setData) .catch((err) => setError(err)); // <- the bug ``` The `catch` cannot tell the difference between "the network died" and "I cancelled this myself", because both arrive as a rejection. So the deliberate cancellation is rendered as a failure. ## Why the state write actually lands Candidates often reply that the write is harmless because the component is gone. It is not gone. Only a dependency changed: the same component instance is still mounted, still rendering, and its `setError` is fully live. React runs the old run's cleanup and then the new run's body, and the rejection from the aborted request settles a moment later — into a component that is very much on screen. The banner shows until something clears the error, which is why it reads as a *flash* rather than a stuck error state: the next successful response replaces it. The unmount case looks calmer but is not better. There the write is a no-op, which just means the same broken code hides its symptom on one path and shows it on the other. ## The fix Guard the catch before it touches state: ```js .catch((err) => { if (err.name === 'AbortError') return; setError(err); }); ``` With `async`/`await` the `await` throws, and the guard is the first line of the `catch` block. The abort also interrupts body reading, so `res.json()` can reject the same way — one guard at the top covers both. An equivalent guard reads the controller's signal instead: `if (controller.signal.aborted) return;`. That is sometimes clearer, because it says the thing you actually mean — *this run has been torn down* — rather than inspecting the error. Either way, the check must come before any state write. ## Do not forget the loading flag The error banner is the visible half. The same mistake usually appears in the `finally` branch: ```js .finally(() => setLoading(false)); // runs on abort too ``` That clears the spinner for a request that was cancelled — and it runs *after* the new run already set `loading` to `true`, so the panel shows "no data, not loading" until the second response arrives. Any teardown-triggered settle must skip the loading write as well, which usually means moving the flag out of `finally` and into the two branches that survive the guard. ## The rule worth generalising **A run that has been cleaned up must not write state.** Once React has called an effect run's cleanup, that run is dead: the next run owns the component's data, its loading flag, and its error slot. Every asynchronous continuation still attached to the dead run — a rejection handler, a `finally`, a timer callback, a resolved parse — has to check that it is still the live run before it does anything visible. That framing also tells you what *not* to do. Suppressing the banner by delaying the abort, or by only aborting on unmount and letting dependency-change requests run to completion, trades a visible bug for a silent one: you are back to paying for responses nobody reads. And swallowing the whole `catch` unconditionally is worse still — now genuine network failures render as an eternally empty panel with no error and no spinner, which is the hardest kind of bug to get a report about. ## How this shows up in review The tell is a `catch` in an effect that fetches and does not mention cancellation at all. If the effect returns a cleanup that aborts, the catch owes you a guard; the two are a pair, and an effect that has one without the other is either leaking work or lying about failures. Reviewing them together is faster than debugging a banner that only appears when someone clicks fast enough.

  • The component was unmounted rather than re-keyed. Does the unguarded catch still cause a visible bug?
    Not a visible one — after unmount the setter is a no-op, so nothing renders. But it is the same defect hiding on a quieter path: the moment the effect re-runs on a dependency change instead, the identical code paints a false error. Fixing it only for the unmount case is fixing the symptom you happened to see rather than the mistake.
  • Why not simply swallow every rejection in an effect that fetches?
    Because then a real network failure, a 500 turned into a throw, or a JSON parse error all vanish, and the panel sits empty with no error and no spinner. Users report "it just doesn't load", with nothing in the console to work from. The guard has to be narrow — skip cancellations specifically, and let every other rejection reach the error state.
  • Where does a stray loading spinner come from in this same effect?
    From clearing the flag in a `finally`, which also runs when the promise rejects from an abort. The dead run sets `loading` back to false after the live run has just set it to true, so the panel shows neither data nor a spinner until the second response lands. Move the flag into the branches that survive the cancellation guard.

saying these in an interview costs you the question

  • An AbortError means the request genuinely failed
  • Setting state after abort is impossible, so the catch is safe
  • Catching and ignoring every rejection is the fix
  • Only abort on unmount so the banner stops appearing
  • Delaying the abort until the new request starts

context