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.
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
| Name | In react-router@8.5.1 | What it gives you |
|---|---|---|
useBlocker | Exported | A Blocker object you drive your own modal from |
unstable_usePrompt | Exported, unstable_ prefixed | A blocking window.confirm with your message |
useBeforeUnload | Exported | A managed beforeunload listener |
IDLE_BLOCKER | Exported | The blocker's idle state, useful in tests |
Prompt | Not exported | Removed 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 stop | useBlocker | beforeunload | A confirm at the action |
|---|---|---|---|
| In-app link click | Yes | No | n/a |
| Browser back / forward inside the app | Yes | No | n/a |
| Tab close, reload, typed URL | No | Yes | n/a |
window.location = … in your own code | No | Yes | n/a |
| Custom dialog markup | Yes | No — browsers show their own text | Yes |
| Destructive submit with no navigation | No | No | Yes |
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 truth | How you get dirty | Cost |
|---|---|---|
| Controlled state | Compare current state with the values you loaded | One equality check you write |
react-hook-form | formState.isDirty | Already computed; subscribe and read it |
| Uncontrolled form | Snapshot new FormData(form) on mount, diff on change | A 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
| Symptom | Cause | Fix |
|---|---|---|
useBlocker throws on mount | Used inside <BrowserRouter> | Switch to createBrowserRouter or framework mode |
| Blocker fires on every filter change | Blocker function returns dirty only | Compare currentLocation with nextLocation |
| Navigation dead after the user cancels | blocker.reset() never called | Call it in the dialog's cancel handler |
| Your custom text never appears on tab close | Browsers ignore beforeunload strings | Accept the native dialog, or keep the custom one for in-app nav |
beforeunload ignored entirely | No user interaction on the page yet | Expected: browsers require a prior interaction |
| Prompt skipped on sign-out | Sign-out does a server redirect, not client nav | Clear the dirty flag before the redirecting action |
import { Prompt } fails to resolve | Removed in v6 | useBlocker, 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.
