This page implements the cancellation discipline outlined in Custom Hooks for Async Error Catching, focused on the failure mode that produces wrong data rather than a crash.

The exact problem #

A user types โ€œkeybโ€ in a search box. Four requests go out as they type. The response for โ€œkeyโ€ is slow; the response for โ€œkeybโ€ is fast. The fast one lands, renders correctly, and then the slow one lands and overwrites it. The user is now looking at results for a query they finished typing half a second ago, with no error, no spinner, and no way to tell anything went wrong.

The same shape appears whenever a component re-fetches on a changing input: tab switches, date-range pickers, paginated tables. Aborting helps, but abort alone does not fix it โ€” a response that arrived before the abort can still be sitting in a .json() parse, and its continuation will happily write state.

Zero-to-working hook #

// useAsync.ts
type State<T> =
  | { status: 'idle'; data: null; error: null }
  | { status: 'loading'; data: T | null; error: null }
  | { status: 'success'; data: T; error: null }
  | { status: 'error'; data: T | null; error: Error };

type Action<T> =
  | { type: 'start' }
  | { type: 'success'; data: T }
  | { type: 'error'; error: Error };

function reducer<T>(state: State<T>, action: Action<T>): State<T> {
  switch (action.type) {
    // Keep previous data during a refetch so the UI does not blank out.
    case 'start':   return { status: 'loading', data: state.data, error: null };
    case 'success': return { status: 'success', data: action.data, error: null };
    case 'error':   return { status: 'error', data: state.data, error: action.error };
  }
}

export function useAsync<T>(
  fn: (signal: AbortSignal) => Promise<T>,
  deps: React.DependencyList,
  { throwOnError = false } = {},
) {
  const [state, dispatch] = useReducer(reducer as React.Reducer<State<T>, Action<T>>, {
    status: 'idle', data: null, error: null,
  });

  // Monotonic run id: the identity of the CURRENT run.
  const generation = useRef(0);

  const run = useCallback(() => {
    const id = ++generation.current;
    const controller = new AbortController();

    dispatch({ type: 'start' });

    (async () => {
      try {
        const data = await fn(controller.signal);
        // The only guard that matters: are we still the current run?
        if (id !== generation.current) return;
        dispatch({ type: 'success', data });
      } catch (err) {
        if (id !== generation.current) return;          // superseded
        if (err instanceof DOMException && err.name === 'AbortError') return;
        dispatch({ type: 'error', error: err instanceof Error ? err : new Error(String(err)) });
      }
    })();

    return () => controller.abort();
  }, deps);

  useEffect(() => run(), [run]);

  // Opt-in: hand the failure to the nearest boundary instead of rendering inline.
  if (throwOnError && state.status === 'error') throw state.error;

  return { ...state, refetch: run } as const;
}
Out-of-order responses and the generation guard Four request bars start at staggered times. Request four resolves before request three. Without a guard, request three's late resolution writes state last and wins. With the generation guard, request three's resolution is compared against the current generation and discarded, leaving request four's data on screen. requests as the user types "k" "ke" "key" "keyb" "keyb" resolves "key" resolves late no guard: last write wins screen shows results for "key" generation guard: late run discarded screen keeps results for "keyb"

Step-by-step #

  1. Increment before the async work starts. ++generation.current runs synchronously at the top of run, so the id is fixed before any await can interleave.
  2. Compare after every await. Both the success and the error paths check id !== generation.current. Checking only in try leaves the error path free to write a stale failure.
  3. Return the abort from the effect. React calls the returned cleanup on dependency change and unmount, which is exactly when a run is superseded.
  4. Swallow AbortError silently. It is the expected consequence of your own cancellation, not a failure worth surfacing โ€” see Aborting In-Flight Fetches with AbortController on Boundary Reset.
  5. Keep old data during a refetch. 'start' preserves state.data, so switching filters shows stale-but-real content with a loading indicator rather than an empty flash.
  6. Choose the error channel per call site. throwOnError: true for a component that is meaningless without its data; the returned error for a widget that can render an inline retry.
Symptom Root cause Guard that fixes it
Old queryโ€™s results appear Out-of-order resolution Generation check before dispatch
Spinner never clears Superseded run dispatched error, current run still loading Generation check on the error path too
Flash of empty content on refetch State reset to null on start Preserve data while loading
Duplicate requests on mount Effect re-created every render Stable run via useCallback with correct deps
Wasted bandwidth on fast typing No cancellation AbortController returned from the effect
Crash report noise AbortError treated as a failure Filter AbortError before dispatch

Choosing between inline error and boundary #

// Inline: the widget can degrade meaningfully on its own.
function RelatedProducts({ id }: { id: string }) {
  const { status, data, error, refetch } = useAsync(
    (signal) => fetchRelated(id, signal), [id],
  );
  if (status === 'error') return <InlineRetry message="Related items unavailable" onRetry={refetch} error={error} />;
  return <ProductGrid items={data ?? []} loading={status === 'loading'} />;
}

// Boundary: without this data the page is meaningless, so let the boundary own it.
function OrderDetail({ id }: { id: string }) {
  const { data } = useAsync((signal) => fetchOrder(id, signal), [id], { throwOnError: true });
  if (!data) return <DetailSkeleton />;
  return <OrderView order={data} />;
}

Rethrowing during render โ€” rather than from the async callback โ€” is what makes the boundary path work at all. A throw inside the promise chain escapes to window.onunhandledrejection and no boundary sees it, the distinction analysed in Error Propagation Strategies.

Where an async failure should surface A decision node asks whether the component is usable without this data. Yes leads to an inline retry box. No leads to a rethrow during render box, which leads to the nearest boundary. A third dashed box marked avoid shows throwing inside the promise chain, which reaches only the global rejection handler. usable without this data? decide per call site yes no inline retry in the widget returned error state rethrow during render nearest boundary renders a fallback avoid: throwing inside the promise chain reaches only window.onunhandledrejection The four states the reducer can represent Four boxes: idle, loading, success and error. Arrows show start moving idle or success into loading, success and error resolving from loading, and a note that loading keeps the previous data so the UI never blanks during a refetch. idle loading keeps previous data success error refetch a superseded run writes nothing at all โ€” it never reaches a transition

Verification #

  1. Delay one response deliberately. In DevTools, throttle a single request to five seconds, type past it, and confirm the late response never changes what is on screen.
  2. Watch the request list. Superseded requests must show as (cancelled); if they complete, the abort is not wired to the effect cleanup.
  3. Assert no stale error. Force the first request to fail slowly and the second to succeed quickly โ€” the UI must end in success, not error.
  4. Check the loading flash. Switching filters should keep the previous rows visible with a loading indicator, never an empty container.
  5. Verify the boundary path. With throwOnError: true, a failure must render the boundary fallback and produce exactly one crash report โ€” the assertions in Unit Testing an Error Boundary with React Testing Library.

Frequently asked questions #

Why does my search box show results for a previous query?

Because responses arrive out of order and, without a generation check, the older response wins by arriving last. Compare each runโ€™s identity against the current one before writing state.

Is aborting enough on its own?

No. Abort stops network work but does not unschedule continuations already queued after an await. The generation check makes the write safe; abort saves bandwidth.

Should the hook throw errors so a boundary catches them?

Offer both. Inline for widgets that can degrade; rethrow during render for components that are meaningless without their data.