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;
}
Step-by-step #
- Increment before the async work starts.
++generation.currentruns synchronously at the top ofrun, so the id is fixed before anyawaitcan interleave. - Compare after every
await. Both the success and the error paths checkid !== generation.current. Checking only intryleaves the error path free to write a stale failure. - Return the abort from the effect. React calls the returned cleanup on dependency change and unmount, which is exactly when a run is superseded.
- Swallow
AbortErrorsilently. It is the expected consequence of your own cancellation, not a failure worth surfacing โ see Aborting In-Flight Fetches with AbortController on Boundary Reset. - Keep old data during a refetch.
'start'preservesstate.data, so switching filters shows stale-but-real content with a loading indicator rather than an empty flash. - Choose the error channel per call site.
throwOnError: truefor a component that is meaningless without its data; the returnederrorfor 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.
Verification #
- 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.
- Watch the request list. Superseded requests must show as
(cancelled); if they complete, the abort is not wired to the effect cleanup. - Assert no stale error. Force the first request to fail slowly and the second to succeed quickly โ the UI must end in
success, noterror. - Check the loading flash. Switching filters should keep the previous rows visible with a loading indicator, never an empty container.
- 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.
Related #
- Custom Hooks for Async Error Catching โ the hook family this belongs to
- Retrying Failed Requests with Exponential Backoff in a React Hook โ adding retry on top of cancellation
- Surfacing React Query Errors Through an Error Boundary โ the same routing decision with a data library