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 use keepalive.
  • The response handling — the .then callbacks 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.

What to do with each kind of pending request Async validation and search requests should be aborted on route change and on tab close, and their responses ignored. A draft autosave should be allowed to finish on route change and sent with keepalive on tab close, with the response updating only storage. A final submit should be allowed to finish on route change, which may warn before leaving, and its response should update a global store or notification rather than the unmounted form. An analytics beacon should use sendBeacon. Request Route change Tab close Response handling async validation abort lost (fine) ignore search / preview abort lost (fine) ignore draft autosave let finish keepalive storage only final submit let finish warn before unload global store, not the form analytics let finish sendBeacon none

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

  1. Classify every request the form makes. Does its outcome matter if the user leaves? That single question sorts requests into cancellable and detached.
  2. Link cancellable requests to a form-scoped AbortController. Abort it in the unmount cleanup; swallow AbortError and nothing else. The same technique protects validators, as in cancelling stale async validation with AbortController.
  3. 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.
  4. 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.
  5. Use keepalive for small final writes on tab close. Flush autosave on pagehide, not beforeunload or unload, which are unreliable on mobile and block the back/forward cache.
  6. 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.
A submit that outlives its form The form sends the submit as a detached request. The user clicks a link and the router unmounts the form, which disposes the scope and aborts only cancellable requests, such as a pending address lookup. The submit continues. When it succeeds, the durable callback records the result in a global store and shows a global notification saying the application was sent. The form's UI callback is skipped because the form is unmounted, and no redirect happens. Form Router Server Global store POST /applications (detached) navigate away: unmount, abort lookups 201 Created → durable callback global toast: application sent The form's own success handler (clear fields, redirect) is skipped because the form is gone; the user stays on the page they chose.

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.

Abort, detach or keepalive? If the request's outcome does not matter once the user leaves, as with validation, search and previews, abort it with the form's AbortController. If it matters and the user is navigating within the app, detach it so it completes and reports to a global store. If it matters and the tab is closing, send a small final write with keepalive from a pagehide handler, or rely on the locally saved draft being synced on the next visit. Outcome irrelevant once the user leaves? Abort yes no User navigating within the app? Detach yes no Tab closing: keepalive Small write from pagehide, or sync next visit.

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.


Related

← Submission State and Optimistic Updates