A half-filled form and a misclicked link are a bad combination. useBlocker(shouldBlock) intercepts in-app navigations so you can ask first; it replaces React Router 5 139,386 's <Prompt>. The argument is a boolean or a function receiving { currentLocation, nextLocation, historyAction }; the hook returns a blocker whose state is "unblocked", "blocked" or "proceeding", with proceed() and reset() to resolve it.
function DraftEditor() {
const [dirty, setDirty] = useState(false);
const blocker = useBlocker(({ currentLocation, nextLocation }) =>
dirty && currentLocation.pathname !== nextLocation.pathname);
return <div>
<textarea onChange={(e) => setDirty(e.target.value.length > 0)} />
{blocker.state === 'blocked' && <p role="alertdialog">You have unsaved changes.{' '}
<button onClick={() => blocker.proceed()}>Leave anyway</button>{' '}
<button onClick={() => blocker.reset()}>Keep editing</button></p>}</div>;
}Comparing pathnames matters: without it, any query-string change useSearchParams writes counts as a navigation and pops the dialog. Call blocker.reset() when the blocking component unmounts or the form is saved, or a stale blocker can leave the router stuck in "blocked".
useBlocker only sees navigations the router controls. Closing the tab, reloading or typing a new address is beforeunload, which browsers restrict to a generic dialog and only after the user has interacted with the page, so a robust editor wires both. For a plain window.confirm() there is unstable_usePrompt({ when, message }), permanently prefixed because browsers disagree about the back button while a synchronous dialog is open.