A leave guard for a form with unsaved changes: it holds the departure, asks whether the work should be discarded, and lets it through only when the answer is yes. Reach for it wherever a screen holds work that only exists in the browser: a long or multi-step form, a profile or settings page, a post, comment or article editor, an invoice, order or booking being filled in, a canvas, diagram or query builder, an upload that has been configured but not started, and any modal a user can wander out of. Common asks it answers: "unsaved changes warning react", "are you sure you want to leave this page", "beforeunload react hook", "useBeforeUnload", "prompt before leaving page react", "block navigation react router", "useBlocker alternative", "next.js unsaved changes warning", "next.js prevent navigation", "confirm before leaving form", "dirty form guard", "warn user before closing tab", "onbeforeunload not working", "react router prompt replacement", "shadcn unsaved changes dialog", "discard changes dialog", "route change confirmation react". The reason to install one rather than write four lines is that the four lines guard the wrong door. beforeunload fires when the browser unloads the document — closing the tab, reloading, typing a new URL, following a link off the site. Clicking a next/link or a React Router Link is none of those: the document stays exactly where it is, the router swaps what is rendered, and the half-filled form is gone without the browser ever being consulted. So the guard everyone ships protects the tab button and lets every single in-app route change walk straight past it, which is the way people actually lose the work. beforeunload is not straightforward on its own terms either. The spec settled on preventDefault() while older engines only look at returnValue having been set, so a handler that does one of the two is silently dead in some browsers. Custom wording is discarded by every current browser, so the message you actually control is the in-app one. And nothing fires at all on a page the user has never clicked or typed into, because browsers require sticky activation before they will interrupt a departure. This component does the browser half correctly and then covers the two departures it cannot see, each with the mechanism that is right for it. Link clicks are caught by a capture-phase listener on document, which runs before React's root listener and therefore before any router's handler; both preventDefault() and stopPropagation() are called, because routers differ on whether they check defaultPrevented and stopping the event short of the React root is true of all of them. Answering "discard" replays the original click on the same anchor, so the router handles it exactly as it would have — no full page reload, no reimplementation of routing. Back and forward are caught with the Navigation API's navigate event, the only thing in a browser that can actually refuse a traversal: popstate is announced after the history entry has already changed, and the usual workaround of pushing a sentinel entry to have something to pop leaves a duplicate entry and a back press that does nothing for the rest of the session. That trade is refused here rather than hidden, so the reach is stated plainly: where window.navigation is missing, back is not intercepted, and the way to cover it — along with navigation your own code starts — is the guard prop, which takes a blocker from your router (React Router's useBlocker drops straight in) and stands the built-in interception down while keeping the browser-level warning. Just as deliberate is what is never held: a router.push the app itself makes, because the redirect after a successful save is exactly that and blocking it traps someone on a form they have already submitted; cross-origin links, which are a real unload and which beforeunload already covers; hash links, downloads, target="_blank", and cmd, ctrl, shift or middle clicks, every one of which leaves this page where it is. Official shadcn/ui has nothing in this area — beforeunload, unsaved, dirty and blocker appear in none of its sixty-three components, and it ships no navigation blocking of any kind. Within pulld it is distinct from confirm-button, which guards a destructive action somebody deliberately clicked; this one interrupts a departure nobody thought of as destructive. It pairs with save-status and form-error-summary on the same screen. The dialog is an alertdialog because it interrupts rather than being asked for, and focus lands on "keep editing" rather than on discard, so an Enter press already on its way to the page cannot answer with the destructive choice; Escape does the same as keep editing, and there is no third way out that would leave the navigation in limbo. Once a departure has been answered the browser stops asking the same question, and starts again the moment the user touches the page still holding unsaved work. useUnsavedChanges() is exported for a dialog of your own, useBeforeUnload() for the browser half alone, every colour is a shadcn token so it follows light and dark, and the whole thing is one file with no dependency beyond the icon.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/unsaved-changes-guard.json"