This page implements one half of the teardown contract in State Reset & Cleanup Protocols — the network half, where the consequences outlive the component.
The exact problem #
A dashboard panel crashes while three requests are in flight. The boundary catches it, the user clicks retry, and the subtree remounts and issues three fresh requests. Then the old requests start landing. Two of them resolve into handlers that still hold references to the old component’s setters; one is a POST that quietly commits a second time.
Symptoms that look unrelated but share this root cause: a chart that flickers back to old data a second after refreshing, a duplicate order, a spinner that never clears because the new request finished first and the old one reset the loading flag afterwards.
Zero-to-working cancellation #
// useAbortableEffect.ts
export function useAbortable(): AbortSignal {
const ref = useRef<AbortController>();
if (!ref.current) ref.current = new AbortController();
useEffect(() => {
const controller = ref.current!;
return () => {
// Runs on unmount — including the unmount caused by a boundary reset.
controller.abort(new DOMException('subtree reset', 'AbortError'));
ref.current = undefined;
};
}, []);
return ref.current.signal;
}
// RevenuePanel.tsx
export function RevenuePanel({ range }: { range: DateRange }) {
const signal = useAbortable();
const [data, setData] = useState<Revenue | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
// A second controller scoped to THIS query, combined with the mount-level
// one, so a range change cancels the query without tearing down the mount.
const queryController = new AbortController();
const combined = AbortSignal.any([signal, queryController.signal]);
(async () => {
try {
const res = await fetch(`/api/revenue?from=${range.from}&to=${range.to}`, { signal: combined });
if (!res.ok) throw new Error(`revenue responded ${res.status}`);
const json = (await res.json()) as Revenue;
// Re-check AFTER every await: abort rejects the fetch, but code that
// was already past its await keeps running.
if (combined.aborted) return;
setData(json);
} catch (err) {
if (isAbort(err)) return; // deliberate cancellation, not a failure
if (combined.aborted) return;
setError(err instanceof Error ? err : new Error('revenue failed'));
}
})();
return () => queryController.abort();
}, [range.from, range.to, signal]);
if (error) throw error; // hand to the boundary
return data ? <RevenueChart data={data} /> : <PanelSkeleton />;
}
export function isAbort(err: unknown): boolean {
return err instanceof DOMException && err.name === 'AbortError';
}
Step-by-step #
- One controller per mount, created lazily. Creating it in a ref keeps the same controller across re-renders while giving each mount its own — which is exactly the granularity a boundary reset operates on.
- Abort in cleanup, not in
componentWillUnmountequivalents scattered around. A single cleanup point means a future contributor cannot add a request that escapes cancellation. - Combine signals with
AbortSignal.any. Query-level changes (a new date range) and mount-level teardown are different reasons to cancel; combining them avoids a bespoke bookkeeping layer. - Filter
AbortErrorfirst. An abort is a success from the app’s point of view. Reporting it as a crash pollutes the dashboards described in Deduplicating Error Noise with Fingerprinting and Sampling. - Re-check
abortedafter everyawait. This is the step teams skip. Rejecting the fetch does not unschedule continuations that were already queued. - Never blanket-abort writes. A cancelled
POSTmay already have committed server-side. Give writes idempotency keys so the retry after reset is safe, then abort freely.
| Request type | Safe to abort on reset? | Why |
|---|---|---|
GET list/detail reads |
Yes | No side effect; the remount refetches |
| Polling or streaming reads | Yes | The new mount opens its own subscription |
POST with an idempotency key |
Yes | A repeat carries the same key; the server dedupes |
POST without a key |
Risky | The server may have committed; abort only hides the response |
PUT/PATCH (idempotent by design) |
Yes | Repeating the same payload converges |
| File upload in progress | Prefer pausing | Aborting discards potentially minutes of transfer |
Idempotency, because abort is not cancellation #
// safeWrite.ts
export async function safeWrite<T>(url: string, body: unknown, signal: AbortSignal): Promise<T> {
// Stable per logical operation — reused verbatim by any retry after a reset.
const key = idempotencyKeyFor(body);
const res = await fetch(url, {
method: 'POST',
signal,
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': key },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`write failed: ${res.status}`);
return (await res.json()) as T;
}
With a key on the request, the sequence “abort mid-write → boundary resets → user retries” produces at most one committed effect, whichever leg the server saw first. Without it, abort is a coin flip. This is the same guarantee the offline queue relies on in Queuing Failed POST Requests with the Background Sync API.
Verification #
- Throttle and reset. Set the network to Slow 3G, crash the panel mid-request, retry, and confirm the DevTools network entry for the old request shows
(cancelled). - Count state writes. Log every
setDatacall with the controller’s identity; after a reset, only the new mount’s identity may appear. - Check crash reports. Aborting must produce zero reports. If
AbortErrorshows up in telemetry, theisAbortfilter is in the wrong place. - Test the double-write. Fire a write, abort mid-flight, retry, and assert the backend holds exactly one record — the idempotency guarantee.
- Look for listener leaks. After fifty crash-and-retry cycles,
getEventListeners(window)in DevTools should show a flat count; growth means something outside this hook is not cleaning up — see Clearing Timers and Subscriptions When a Boundary Unmounts.
One subtlety worth knowing: AbortSignal.timeout() produces a signal that aborts on its own schedule, and combining it with a mount-scoped signal through AbortSignal.any() gives you request timeouts and teardown cancellation in a single object without any bookkeeping of your own.
Frequently asked questions #
Does aborting a fetch stop the server from processing the request?
No. Abort closes the client’s side; the server may already have committed. Write requests need idempotency keys — cancellation prevents your UI acting on the response, not the effect happening.
Why do I still see stale-update bugs after adding abort?
Because abort only rejects the fetch promise; code already past an await still runs. Check signal.aborted immediately before every state update, not just at the top of the function.
Should I share one AbortController across the whole app?
No — a shared controller cancels unrelated work. Scope one per mounted subtree and combine signals with AbortSignal.any when a request has several cancellation reasons.
Related #
- State Reset & Cleanup Protocols — the full teardown contract this implements
- Clearing Timers and Subscriptions When a Boundary Unmounts — the non-network half of the same problem
- Adding a Retry Button That Recovers Without a Full Reload — the reset this cleanup runs underneath