When a user submits and immediately clicks a link, the pending request is either killed halfway (the save never happens but the user thinks it did), or it completes and its callback updates a component that no longer exists — setting state, showing a toast on the wrong page, or redirecting the user a second time.
Submission state and optimistic updates covers the request’s lifecycle while the form is on screen. This page covers the moment the form stops being on screen. The right answer depends on the request: a search or validation request should be aborted; a save should usually be allowed to finish; and neither should touch UI that has gone.
Context and prerequisites
Three separate things happen to a request when its form goes away, and they are often confused:
- The request itself — does it keep going? In a single-page app, navigating to another route does not cancel
fetch; the request continues unless you abort it. Closing or reloading the tab does cancel ordinary requests, unless they usekeepalive. - The response handling — the
.thencallbacks still run when the response arrives, even if the component is unmounted, and may update state or trigger navigation. - The server’s work — aborting on the client does not undo work the server already did. An aborted save may still have been committed.
So the design question per request is: should this outcome still happen if the user leaves? Saves, payments and submissions: yes — let them finish, detach the UI. Validation, search, previews: no — abort them.
The core pattern: scope requests to the form, and decide per request
export class FormRequestScope {
// Aborted when the form unmounts. Every cancellable request links to it.
private controller = new AbortController();
private mounted = true;
get signal(): AbortSignal { return this.controller.signal; }
/** For requests that should die with the form (validation, search). */
cancellable<T>(run: (signal: AbortSignal) => Promise<T>): Promise<T | undefined> {
return run(this.controller.signal).catch((err) => {
// AbortError is expected on unmount: swallow it, rethrow everything else.
if (err?.name === "AbortError") return undefined;
throw err;
});
}
/**
* For requests that must complete even if the user leaves (saves, submits).
* The request is NOT linked to the scope's signal. Its UI callback runs only
* while mounted; the durable callback always runs.
*/
detached<T>(
run: () => Promise<T>,
onDurable: (r: T) => void, // e.g. update a global store / show a global toast
onUi?: (r: T) => void, // e.g. clear this form's pending state
): Promise<T> {
return run().then((r) => {
onDurable(r);
if (this.mounted) onUi?.(r);
return r;
});
}
dispose() {
this.mounted = false;
// reason gives aborted requests a recognisable cause in logs
this.controller.abort(new DOMException("form unmounted", "AbortError"));
}
}
// React usage
function useFormRequestScope() {
const scopeRef = useRef<FormRequestScope>();
scopeRef.current ??= new FormRequestScope();
useEffect(() => () => scopeRef.current!.dispose(), []);
return scopeRef.current;
}
For the tab-close case, send autosave with fetch(url, { method: "POST", body, keepalive: true }) from a pagehide handler; keepalive lets the request outlive the page, with a combined body limit of 64 KiB for in-flight keepalive requests.
Step-by-step walkthrough
- Classify every request the form makes. Does its outcome matter if the user leaves? That single question sorts requests into cancellable and detached.
- Link cancellable requests to a form-scoped
AbortController. Abort it in the unmount cleanup; swallowAbortErrorand nothing else. The same technique protects validators, as in cancelling stale async validation with AbortController. - Detach saves and submits from the form. Their results go to something that outlives the form — a global store, a notification centre, the router cache — and only touch the form’s own UI if it is still mounted.
- Warn before leaving during a submit. While a final submit is pending, the unsaved-changes guard from warning before leaving a form with unsaved changes can say “Your application is still sending” for in-app navigation.
- Use
keepalivefor small final writes on tab close. Flush autosave onpagehide, notbeforeunloadorunload, which are unreliable on mobile and block the back/forward cache. - Never navigate from a detached callback. “Redirect to the confirmation page on success” must check that the user is still on the form’s route; otherwise a completed save yanks them away from wherever they went.
Failure modes and edge cases
1. “Can’t perform a state update on an unmounted component”
Older React versions warned about this; newer ones stay silent, but the bug remains — a callback updating state that nobody renders, or worse, a callback that navigates. Route detached results through a store and guard UI callbacks with a mounted check, as above.
2. Aborting a save you meant to keep
Linking every request to one unmount signal is tempting and wrong for saves. An aborted POST may or may not have reached the server; the user sees no confirmation and may redo the work, creating a duplicate. Detach saves and rely on idempotency.
3. React Strict Mode double effects
In development, React mounts, unmounts and remounts components, which disposes the first scope immediately. Create the scope in a way that survives this — the lazy useRef above plus disposal in cleanup works, as long as nothing started in the first mount is expected to succeed.
4. keepalive limits
Keepalive requests share a 64 KiB in-flight body budget per page; larger bodies make fetch reject. Keep flushes small (a diff, not the whole draft), or save the draft locally and let the next visit sync it.
5. Router-level cancellation
Some frameworks abort loader and action requests on navigation automatically (React Router passes a request.signal that aborts when a navigation is interrupted). That is correct for data loading and must be considered carefully for mutations; see form validation with React Router actions.
Verification checklist
Frequently Asked Questions
Does navigating in a single-page app cancel fetch requests?
No. Client-side routing only swaps components; fetch promises keep running and their callbacks still execute. Only a full page unload (reload, close, cross-document navigation) cancels ordinary requests. That is why form-scoped aborting is explicit.
If I abort a POST, did the server receive it?
Possibly. Aborting stops the client waiting and may close the connection, but the server may already have processed the request. Treat an aborted mutation as “unknown outcome” and design with idempotency keys so a retry is safe.
Should I use beforeunload to block tab close during a submit?
A beforeunload prompt during a pending submit is one of the few justified uses, and it is only shown if the user has interacted with the page. Remove the listener as soon as the submit settles, because it prevents the page from entering the back/forward cache while registered.