Skip to content

Unsaved Changes

Asks before unsaved changes are lost, when leaving a page or when closing a dialog.

npx shadcn@latest add @sureui/unsaved-changes

tsx
import { useUnsavedChanges } from "@/components/ui/sureui/unsaved-changes"const { confirmLeave, dialog } = useUnsavedChanges({ when: dirty })async function openBilling() {  if (await confirmLeave()) navigate("/settings/billing")}return (  <>    <form>...</form>    {dialog}  </>)

While when is true, reloading or closing the tab shows the browser's own prompt, and confirmLeave() opens a dialog with Keep editing and Discard changes. It resolves true when it's fine to leave. While when is false it resolves true right away.

The hook doesn't know your router. Call confirmLeave() before any navigation you start.

Save from the dialog

With onSave, the dialog offers Save and stays open until it settles. Discard changes then asks once more before the edits are thrown away. onDiscard runs once they are.

// Nothing has run yet

In a dialog

The same hook asks before a Dialog, Sheet or Drawer with unsaved edits closes. It works with one already in your app and doesn't install one. If you don't have one yet, run npx shadcn@latest add dialog.

// Nothing has run yet

tsx
const { rootProps, question, close } = useUnsavedChanges({  when: dirty,  onDiscard: resetForm,})<Dialog {...rootProps}>  <DialogContent>    <ProfileForm onSaved={close} />    <DialogFooter>{question ?? <FormButtons />}</DialogFooter>  </DialogContent></Dialog>

Spread rootProps on the root. While when is true, Escape, a click outside or the × button keeps it open and sets question: Keep editing and Discard changes, plus Save with onSave. Render it in place of your footer buttons, so the dialog keeps its size. While when is false, it closes straight away and question is null. close() closes without asking. Call it after saving, or from a Cancel button after resetting the form.

useUnsavedChanges(options)

PropTypeDefault
whenbooleanrequired
onSave() => void | Promise<unknown>—
onDiscard() => void | Promise<unknown>—
onConfirmError(error: unknown) => void—
beforeUnloadbooleantrue
titlestring"Discard unsaved changes?"
saveTitlestring"Save changes before leaving?"
descriptionReactNode"Your changes haven't been saved."
keepLabelstring"Keep editing"
discardLabelstring"Discard changes"
saveLabelstring"Save"
openboolean—
onOpenChange(open: boolean) => void—

beforeUnload={false} leaves reloads and closed tabs alone, for when your router already prompts. open and onOpenChange control the dialog that rootProps goes on.

If onSave or onDiscard returns a promise, its button stays pending until it settles, and nothing closes unless it succeeds. If it fails, the buttons stay and the error goes to onConfirmError, as on Confirm Button.

NameTypeDescription
confirmLeave() => Promise<boolean>Opens the confirm dialog while when is true. Resolves true once the changes are saved or discarded, and false on Keep editing, Escape or unmount. Called again while the dialog is open, it returns the same promise.
dialogReactElementThe confirm dialog for confirmLeave. Render it once.
rootProps{ open, onOpenChange }Props for your Dialog, Sheet or Drawer root.
questionReactElement | nullThe buttons to render in its footer while asking, otherwise null.
close() => voidCloses your dialog without asking.
  • The dialog is a Confirm Dialog: it has the alertdialog role, named by the title and described by the description.
  • Without onSave, Keep editing has focus when the dialog opens, so Enter doesn't throw anything away.
  • Focus starts on Keep editing, the choice that loses nothing. While Save is pending, the other buttons and Escape do nothing.
  • Browsers show their own text in the unload prompt, and only after someone has interacted with the page.
  • In a dialog, the question announces title, or saveTitle with onSave, in a polite live region and moves focus to Keep editing. Keep editing returns focus to where it was, and while it's asking, Escape and outside clicks do nothing more.