Pattern

Unsaved Changes

A pattern for detecting and handling unsaved form changes with navigation blocking and a floating save bar.

Overview

The Unsaved Changes pattern provides a complete solution for handling form state that needs to be saved before navigation. It includes:

  • Hook-based API: Track dirty state and handle save/discard logic
  • Navigation blocking: Prevents accidental navigation when changes exist
  • Floating save bar: A persistent UI element showing unsaved status with save/discard actions

Installation

npx shadcn@latest add https://ui-registry.com/r/unsaved-changes

Usage

With Hook

import { useUnsavedChanges } from "@/hooks/use-unsaved-changes"
 
function EditForm() {
  const [formData, setFormData] = useState(initialData)
 
  const { isDirty, setIsDirty, handleSave, handleDiscard } = useUnsavedChanges({
    onSave: async () => {
      await saveFormData(formData)
    },
    onDiscard: () => {
      setFormData(initialData)
    },
  })
 
  function handleChange(value: string) {
    setFormData(prev => ({ ...prev, value }))
    setIsDirty(true)
  }
 
  return (
    <form>
      <Input value={formData.value} onChange={e => handleChange(e.target.value)} />
      {isDirty && (
        <div>
          <Button onClick={handleDiscard}>Discard</Button>
          <Button onClick={handleSave}>Save</Button>
        </div>
      )}
    </form>
  )
}

With Floating Bar

import { UnsavedChangesProvider, UnsavedChangesBar } from "@/components/unsaved-changes"
 
function EditPage() {
  return (
    <UnsavedChangesProvider
      onSave={handleSave}
      onDiscard={handleDiscard}
    >
      <EditForm />
      <UnsavedChangesBar />
    </UnsavedChangesProvider>
  )
}

With Form Integration

import { useForm } from "react-hook-form"
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes"
 
function EditForm() {
  const form = useForm({ defaultValues: initialData })
 
  const { isDirty } = useUnsavedChanges({
    isDirty: form.formState.isDirty,
    onSave: form.handleSubmit(onSubmit),
    onDiscard: () => form.reset(),
  })
 
  return (
    <Form {...form}>
      {/* Form fields */}
    </Form>
  )
}

API

useUnsavedChanges Hook

const {
  isDirty,
  setIsDirty,
  isSaving,
  handleSave,
  handleDiscard,
} = useUnsavedChanges(options)

Options

OptionTypeDefaultDescription
isDirtyboolean-External dirty state (for controlled usage)
onSave() => Promise<void>-Async function to save changes
onDiscard() => void-Function to discard changes
blockNavigationbooleantrueWhether to block navigation when dirty

UnsavedChangesBar Props

PropTypeDefaultDescription
position"top" | "bottom""bottom"Position of the floating bar
saveTextstring"Save changes"Text for save button
discardTextstring"Discard"Text for discard button
messagestring"You have unsaved changes"Status message

Behavior

  • Navigation blocking: Uses beforeunload event and Next.js router events to prevent accidental navigation
  • Confirmation dialog: Shows a confirmation when user attempts to navigate with unsaved changes
  • Keyboard shortcut: Cmd/Ctrl + S to save (when enabled)
  • Auto-save: Optional auto-save functionality with debouncing

Keyboard Interactions

KeyDescription
Cmd/Ctrl + SSave changes (when keyboard shortcuts enabled)
EscapeFocus the discard button in the floating bar

Best Practices

  • Always provide clear feedback when changes are saved
  • Use optimistic UI for better perceived performance
  • Consider auto-save for long forms
  • Make the floating bar dismissible but not easy to accidentally close