Unsaved Changes Guard
A three-way exit for unsaved work — beforeunload armed only while dirty, in-app navigation held through an injected navigate handler, and a save / discard / stay dialog instead of the browser's bare prompt.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/unsaved-changes-guard.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "UnsavedChangesGuard" component on top of
the shadcn/Radix AlertDialog and Button primitives (lucide-react for two icons).
It stands between a half-filled form and everything that would throw it away:
the browser exit (reload, tab close) and the in-app exit (any navigation the app
routes through it).
Contract
- forwardRef<HTMLDivElement>. Props extend
Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "title">; leftover
props spread on the root div and className is merged last through cn().
- dirty: boolean — the single input the whole guard hangs on. The consumer owns
it: draft !== persisted, react-hook-form's formState.isDirty, a store
selector. The guard never derives it and never mutates it.
- navigate: (to: string) => void — the real navigation, injected. The component
imports no router, so the same file works with the App Router, the Pages
Router, TanStack Router, or a plain setState in a preview.
- onSave?: () => void | Promise<void> — returning a promise buys the pending
state. Omit the prop entirely and the dialog drops to two answers; never
render a save button that cannot save.
- onDiscard?: () => void — where the consumer drops the draft. Called before
the navigation runs.
- guardReload = true — arm the browser-level beforeunload listener.
- title = "Leave with unsaved changes?", description (ReactNode, rendered into a
<p>, so inline content only), saveText = "Save and leave",
discardText = "Discard changes", cancelText = "Keep editing",
savingText = "Saving…" — flat string props, so the whole surface translates
without a nested config object.
- children: React.ReactNode | ((api) => React.ReactNode), where api is
{ requestNavigation: (to: string) => void; blocking: boolean; saving: boolean }.
Consumers call requestNavigation from a link's onClick after preventDefault(),
or anywhere they would otherwise have called the router directly. Export the
render-prop type next to the props type.
Behavior — the browser exit, and its ceiling
- One effect keyed on [dirty, guardReload]: while both are true, add a
beforeunload listener that calls event.preventDefault() and sets
event.returnValue = true. Truthy, not the empty string — per spec an empty
returnValue cancels nothing, and the assignment only exists for engines older
than Chrome 119. The cleanup removes the listener, which is the entire reason
this is an effect and not a mount-once subscription: the second dirty goes
false, a saved page must stop prompting.
- State the ceiling plainly instead of pretending: that prompt is generated by
the browser. Its wording cannot be set, its appearance cannot be styled, it
offers two answers and never a third, and it only fires once the visitor has
actually interacted with the document (sticky activation) — an untouched page
closes silently. Everything worth designing lives in the in-app exit.
Behavior — the in-app exit, which is the real component
- requestNavigation(to):
- a save is in flight -> ignore, the running decision owns the dialog;
- !dirty -> call navigate(to) straight away. A clean form must never see a
dialog, and no beforeunload listener exists at that moment either;
- dirty -> write `to` into a ref AND into state, and clear any previous
error. The state opens the dialog; the ref is what the handlers act on.
- Why both: state is one render behind, so two clicks on Discard inside a single
tick would both read the same destination and navigate twice. Every exit path
therefore goes through one takeTarget() helper that reads the ref and nulls it
in the same statement pair — a read-and-write one-shot. If it comes back null,
the decision has already been spent and the handler returns.
- Keep editing / Esc / a swallowed close: null the ref and the state, clear the
error. Nothing else happens; the draft is untouched.
- Discard changes: call onDiscard?.(), take the destination, navigate. The guard
resets nothing itself — resetting is the consumer's job, and it is what flips
dirty back to false.
- Save and leave: call onSave(). If it returns a promise, enter pending — the
three buttons go aria-disabled, the dialog stays open, Esc is
preventDefault()-ed and every onOpenChange(false) is swallowed. On resolve,
take the destination and navigate. On reject (or a synchronous throw), leave
pending, keep the dialog open, and render the message inline in a
destructive-tinted paragraph with role="alert". Crucially the destination was
never taken, so it survives the failure: retry is one click and the user never
has to hunt for the link again.
- Guard the async tail: a savingRef mirrors the pending flag for the same
one-tick reason as the destination, and an aliveRef (set true in the effect
BODY, false in its cleanup, or StrictMode's mount/cleanup/mount leaves it
false forever) stops a late resolution from navigating an unmounted tree.
- Not covered on purpose, and worth saying out loud: the browser's own Back
button cannot be intercepted in the App Router. Route your in-app links
through requestNavigation; a Back press is the one exit that still gets the
browser's plain behaviour.
Rendering & styling
- Semantic tokens only, no hex/rgb/oklch: the panel is AlertDialogContent
(bg-popover), the failure is bg-destructive/10 + text-destructive, the warning
glyph sits in an AlertDialogMedia slot as text-muted-foreground.
- The root is a plain div that renders children plus the dialog, with
tabIndex={-1} so it can act as a focus successor, outline-none and
focus-visible:ring-2 focus-visible:ring-ring/50.
- Buttons use aria-disabled + a handler guard, never the native disabled
attribute: the user may be focused on the button that goes inert, and the
browser blurs a node the instant it becomes disabled, dropping focus on
<body>. Pair it with aria-disabled:pointer-events-none
aria-disabled:opacity-50.
- Footer order in the DOM is Cancel, Discard, Save. Cancel first is deliberate:
Radix parks the initial focus on AlertDialogCancel, so a stray Enter keeps the
work. AlertDialogFooter reverses the column on small screens, which puts the
primary answer on top there and on the right on desktop.
- The save button is deliberately NOT an AlertDialogAction — Action closes the
dialog on click, which forecloses pending states and inline failures. A plain
<Button> on the same handler keeps the semantics and gains the state machine.
- Reduced motion: the only animation is the pending spinner, which carries
motion-reduce:animate-none. The dialog still opens, still saves, still leaves.
Keyboard and ARIA contract
- Tab / Shift+Tab cycle inside the dialog (Radix focus trap). Enter or Space
activates the focused button. Esc answers "Keep editing", except while saving,
where it is preventDefault()-ed.
- Initial focus: the Cancel button. Closing after staying: focus goes back to the
control that asked to leave — remember document.activeElement when the
destination is held and restore it here, because the primitive aims its own
restore at a Trigger that a controlled dialog does not have, and would drop
focus on <body> instead. Closing after leaving: that same control was probably
just unmounted by the navigation and restoring to it
drops focus on <body>. Radix dispatches onCloseAutoFocus on the panel itself,
so check document.activeElement: if it is still inside the panel, or is
<body>, focus the guard root; if something outside claimed focus (a
destination heading, say), leave it alone.
- The dialog is labelled and described by AlertDialogTitle /
AlertDialogDescription. One permanently mounted sr-only role="status" carries
the pending label, because a button swapping its own text inside an open
dialog is not announced on its own. The spinner is aria-hidden.
Customization levers
- Wording: title / description / saveText / discardText / cancelText /
savingText cover the entire surface. Keep the answers as verbs about the
destination ("Save and leave", not "OK").
- Answer count: drop onSave for a two-answer dialog (a page that cannot save
on the user's behalf), or give it a save that only stores a local draft.
- Reach: guardReload={false} turns off the browser half and leaves the in-app
half intact — the right setting inside a preview, a docs page, or an embedded
editor where the surrounding page owns the reload story.
- Blocking scope: requestNavigation is the only entry point, so what is guarded
is exactly what the consumer routes through it. Wrap it in a helper link
component and the whole navigation surface is covered in one place.
- Severity: swap the AlertDialogMedia glyph, or drop the media slot for a
quieter dialog; switch the discard button to variant="outline" if leaving is
routine rather than costly in your product.
- Layout: className lands on the root div, so the guard can be the layout box of
the region it protects (a card, a form column) or a transparent wrapper.
- Autosave pairing: drive dirty from the same state your autosave watches, and
point onSave at the same mutation — the dialog becomes a manual trigger for a
save that was going to happen anyway.Concepts
- Two exits, one dirty flag — a reload and a sidebar click destroy the same draft, so both hang off the same boolean: the
beforeunloadlistener is added and removed by an effect keyed on it, andrequestNavigationshort-circuits on it. Nothing to lose means nothing to ask. - The prompt you cannot design —
beforeunloadis a ceiling, not a feature: the browser writes the words, owns the styling, offers two answers, and stays silent until the visitor has really interacted with the page. The in-app path exists precisely because it can offer the third answer the browser never will. - A held destination — the guard does not cancel the navigation, it postpones it: the target is parked in a ref and replayed through the injected
navigateonce an answer arrives. Keeping it in a ref rather than only in state is what makes the replay a read-and-clear one-shot, so a double click cannot navigate twice. - Refusal keeps the destination — a save that rejects leaves the dialog open, the message inline and the target still parked, so retrying costs one click. Taking the target on the way in would have thrown away the only thing the user was trying to do.
- Focus after leaving — the link that asked to leave is usually the first casualty of the navigation, so restoring focus to it lands on
<body>. The guard prevents Radix's restore, checks whether the destination claimed focus for itself, and otherwise parks focus on the region that survived. - The guard never resets your draft —
onDiscardis a notification, not a mutation. State stays where it belongs, and the consumer resetting it is what flipsdirtyback to false and disarms the whole guard.
Maintenance Banner
A scheduled-window notice that counts down to the start, switches to an in-progress state with an elapsed bar, and clears itself once the window ends — every verdict derived from an injected instant.
Progress Toast
A toast bound to one long job — determinate or indeterminate progress, a live percentage in its accessible name, cancel while it runs, and terminal states that stay long enough to read with a retry on failure.