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-changesUsage
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
| Option | Type | Default | Description |
|---|---|---|---|
isDirty | boolean | - | External dirty state (for controlled usage) |
onSave | () => Promise<void> | - | Async function to save changes |
onDiscard | () => void | - | Function to discard changes |
blockNavigation | boolean | true | Whether to block navigation when dirty |
UnsavedChangesBar Props
| Prop | Type | Default | Description |
|---|---|---|---|
position | "top" | "bottom" | "bottom" | Position of the floating bar |
saveText | string | "Save changes" | Text for save button |
discardText | string | "Discard" | Text for discard button |
message | string | "You have unsaved changes" | Status message |
Behavior
- Navigation blocking: Uses
beforeunloadevent 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 + Sto save (when enabled) - Auto-save: Optional auto-save functionality with debouncing
Keyboard Interactions
| Key | Description |
|---|---|
Cmd/Ctrl + S | Save changes (when keyboard shortcuts enabled) |
Escape | Focus 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