Skip to main content
ASoc
Tutorial

React Router Prompt: Blocking Navigation After Prompt Was Removed

Prompt left React Router at v6. What 8.5.1 actually exports, what the react-router-prompt package costs, and the App Router equivalent.

The ASoc Team8 min read

React Router's <Prompt> component was removed in v6 and never returned. In react-router@8.5.1 the replacements are useBlocker, for a dialog you render yourself, and unstable_usePrompt, for a one-line window.confirm. Neither one stops a tab close or a reload — that is still beforeunload, a separate browser API with separate rules.

What the package actually exports today

Tutorials for "react router prompt" and "react router dom prompt" mostly target v4 and v5, where <Prompt when={isDirty} message="…" /> was a component you dropped into a route. That component is gone. Rather than trust a changelog, unpack the published package and read its type declarations:

npm pack react-router@8.5.1
tar -xzf react-router-8.5.1.tgz
grep -roh "usePrompt\|useBlocker\|useBeforeUnload" package/dist | sort | uniq -c
NameIn react-router@8.5.1What it gives you
useBlockerExportedA Blocker object you drive your own modal from
unstable_usePromptExported, unstable_ prefixedA blocking window.confirm with your message
useBeforeUnloadExportedA managed beforeunload listener
IDLE_BLOCKERExportedThe blocker's idle state, useful in tests
PromptNot exportedRemoved in v6; no component API replaced it

The unstable_ prefix is the part worth noticing before you build on it. In the published declaration file the hook is re-exported as usePrompt as unstable_usePrompt, which is the library telling you the signature may change. useBlocker carries no prefix, so it is the one to write against if you want the call site to survive an upgrade.

Which layer blocks what

Navigation blocking is three different mechanisms that people discuss as one. They do not overlap as much as you would hope.

You want to stopuseBlockerbeforeunloadA confirm at the action
In-app link clickYesNon/a
Browser back / forward inside the appYesNon/a
Tab close, reload, typed URLNoYesn/a
window.location = … in your own codeNoYesn/a
Custom dialog markupYesNo — browsers show their own textYes
Destructive submit with no navigationNoNoYes

That third column is the one most teams end up needing. A prompt on the way out protects unsaved text; it does nothing about a button that deletes an account, because deleting does not navigate.

useBlocker, with your own dialog

useBlocker requires a data router — createBrowserRouter or the framework mode. Inside a plain <BrowserRouter> it throws, which is the single most common "why doesn't this work" report on the hook.

import { useBlocker } from "react-router";

export function UnsavedChangesGuard({ dirty }: { dirty: boolean }) {
  const blocker = useBlocker(
    ({ currentLocation, nextLocation }) =>
      dirty && currentLocation.pathname !== nextLocation.pathname,
  );

  if (blocker.state !== "blocked") return null;

  return (
    <div role="dialog" aria-modal="true" aria-labelledby="leave-title">
      <h2 id="leave-title">Leave without saving?</h2>
      <button type="button" onClick={() => blocker.proceed()}>
        Discard changes
      </button>
      <button type="button" onClick={() => blocker.reset()}>
        Keep editing
      </button>
    </div>
  );
}

Two details decide whether this behaves: the blocker function should compare locations, or a search-param change counts as leaving the page; and blocker.reset() has to be called on cancel, or the blocker stays blocked and every later navigation silently fails.

The react-router-prompt package (0.8.2, 13,563 bytes unpacked across 10 files, measured from the published tarball) wraps exactly this into a render-prop component. It is a thin convenience: worth it if you want the same dialog in a dozen routes, not worth a dependency for one form.

What this codebase does instead

This storefront is Next.js, not React Router, so nothing above is a benchmark from our production traffic. What is first-party is the design decision, because the same question came up and the answer was no.

Three forms here hold text a user would hate to lose:

  • src/components/molecules/ContactForm.tsx — the message body on /contact.
  • src/components/molecules/NewsletterForm.tsx — one email field in the footer.
  • src/components/molecules/SettingsForm.tsx — the dashboard's profile, email, password and delete-account cards.

Navigation prompts in the repository: zero. No beforeunload listener, no blocker, no guard. The reason is in the third file. The risk on an account page is not a half-typed form, it is an irreversible action, so the gate sits at the action rather than on the exit:

const [confirmText, setConfirmText] = useState("");
const canSubmit = !confirmPhrase || confirmText === confirmPhrase;

<button type="submit" disabled={pending || !canSubmit}>
  {pending ? (pendingLabel ?? "Saving…") : submitLabel}
</button>;

That is SettingsForm as it ships. With confirmPhrase set, the card starts collapsed behind a trigger, and the submit button stays disabled until the exact phrase is typed. The server action re-checks the same field, so the typed phrase is a UX gate and not the security boundary — which is the rule worth carrying over to any blocker you write. A dialog that a user can dismiss has never authorized anything.

The other two forms submit in one step and lose nothing worth a modal. A prompt there would interrupt every visitor who clicked into the footer by accident to protect a field they can retype in four seconds.

The App Router equivalent

If you are on Next.js rather than React Router, there is now a direct analogue. Next 16's <Link> takes an onNavigate handler, and the event it receives has preventDefault():

"use client";
import Link from "next/link";
import { useEffect } from "react";

export function GuardedLink({
  dirty,
  href,
  children,
}: {
  dirty: boolean;
  href: string;
  children: React.ReactNode;
}) {
  useEffect(() => {
    if (!dirty) return;
    const warn = (e: BeforeUnloadEvent) => e.preventDefault();
    window.addEventListener("beforeunload", warn);
    return () => window.removeEventListener("beforeunload", warn);
  }, [dirty]);

  return (
    <Link
      href={href}
      onNavigate={(e) => {
        if (dirty && !window.confirm("Leave without saving?")) {
          e.preventDefault();
        }
      }}
    >
      {children}
    </Link>
  );
}

The limit is the same one useBlocker has from the other direction: onNavigate fires only for client-side navigation through <Link>. A router.push() elsewhere in your code, a browser back button, or a hard link will not reach it, which is why the beforeunload listener is in the same component rather than instead of it. Our own routing conventions are in navigating to another page in Next.js.

Where the dirty flag comes from

Every example above takes dirty as a boolean, and that is the part a tutorial usually skips. Three ways to produce it, in rising order of cost:

Source of truthHow you get dirtyCost
Controlled stateCompare current state with the values you loadedOne equality check you write
react-hook-formformState.isDirtyAlready computed; subscribe and read it
Uncontrolled formSnapshot new FormData(form) on mount, diff on changeA listener plus a diff per keystroke

The trap in the third row is that a re-render which re-reads defaults resets your snapshot, so the form looks clean the moment anything above it re-renders. If you are already on a form library, use its flag — our notes on that stack are in react-hook-form with React 19, and the plain-React version of a submit handler is in building a React contact form.

A flag that is wrong in the optimistic direction is worse than no guard at all: the dialog never appears, so nobody tests it, and the regression ships. Set it from the one place that knows the form's values.

Troubleshooting

SymptomCauseFix
useBlocker throws on mountUsed inside <BrowserRouter>Switch to createBrowserRouter or framework mode
Blocker fires on every filter changeBlocker function returns dirty onlyCompare currentLocation with nextLocation
Navigation dead after the user cancelsblocker.reset() never calledCall it in the dialog's cancel handler
Your custom text never appears on tab closeBrowsers ignore beforeunload stringsAccept the native dialog, or keep the custom one for in-app nav
beforeunload ignored entirelyNo user interaction on the page yetExpected: browsers require a prior interaction
Prompt skipped on sign-outSign-out does a server redirect, not client navClear the dirty flag before the redirecting action
import { Prompt } fails to resolveRemoved in v6useBlocker, or unstable_usePrompt for a confirm

Frequently asked questions

Can I still use <Prompt> from react-router-dom? Only by staying on v5. The component has no equivalent in v6 or later; react-router-dom now re-exports from react-router, and neither package ships it.

Is unstable_usePrompt safe to ship? It works, and the unstable_ prefix is the honest warning that its name or signature can change in a minor release. Wrap it in one hook of your own so an upgrade is a one-file edit.

Do I need a prompt at all? Often not. Autosaving a draft, or gating the destructive action the way SettingsForm does, removes the reason for the dialog. A modal that most people dismiss reflexively trains them to dismiss the one that mattered.

Does any of this block a server-rendered redirect? No. A redirect() from a server action or a 302 from a route handler is not client-side navigation, and nothing in the browser offers to stop it. Decide before you kick off the request, not after.

Templates in this post

ASoc Brief is a résumé and portfolio site whose icon-rail switches between six views in a single page, ASoc Byte is a product-engineering studio landing page with a capability grid and testimonial wall, and ASoc Canvas is a page-builder marketing site with an editor mock, a template library and a full pricing comparison. All three ship their forms as plain, prop-driven components, so adding a guard is one hook in one file.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial10 min read

A React Sidebar in 60 Lines and Zero JavaScript

Sticky positioning without a scroll listener, aria-current for the active row, and the hard-coded active-state bug that only appears when you add a second page.

Read more