Dirty form
Tracks which fields changed from their initial values and powers the save bar.
import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { SaveBar } from "@oration/canon/components/save-bar";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";import * as React from "react";export function Hero() { const id = React.useId(); const form = useDirtyForm( { displayName: "Cedarline Payables", replyTo: "remittances@cedarline.com", emailAdvice: true, }, { onSave: async () => { await new Promise((resolve) => setTimeout(resolve, 700)); toast.add({ type: "success", title: "Changes saved" }); }, }, ); return ( <div className="flex w-full max-w-md flex-col"> <div className="flex flex-col gap-4 rounded-xl bg-card p-4 shadow-border"> <div className="flex flex-col gap-1.5"> <Label htmlFor={`${id}-name`}>Display name</Label> <Input id={`${id}-name`} value={form.values.displayName} onChange={(event) => form.set("displayName", event.target.value) } /> </div> <div className="flex flex-col gap-1.5"> <Label htmlFor={`${id}-reply`}> Remittance reply-to address </Label> <Input id={`${id}-reply`} type="email" value={form.values.replyTo} onChange={(event) => form.set("replyTo", event.target.value) } /> </div> <Switch label="Email remittance advice to suppliers" checked={form.values.emailAdvice} onCheckedChange={(checked) => form.set("emailAdvice", checked) } className="px-0" /> </div> <SaveBar dirty={form.isDirty} changes={form.dirtyCount} saving={form.saving} onDiscard={form.reset} onSave={() => void form.save()} /> </div> );}Usage#
useDirtyForm keeps a form's values next to the last saved baseline and reports which fields differ, which is the count the save bar shows. It owns values, set, reset, save and saving, compares field by field, and only moves the baseline when onSave resolves. Settings pages, agent config and record detail forms are built on it. The usual mistakes are counting edits instead of differences, so a field typed back to its saved value still reads as changed, and loading data with setValues, which marks every field dirty.
When to use
- Settings pages and config panels that save explicitly through the save bar.
- Record detail forms where several fields change before one save.
- Anywhere a 3 unsaved changes count, Discard and ⌘S should just work.
changedKeys(saved, values)on its own, to diff two snapshots for an audit line or a confirmation summary.
When not to use
- Settings that apply the moment they change. Toggle them and confirm with a toast. Use Switch
- A short form in a dialog with one submit button. Use Form dialog
- Editing a single value in place, such as a record name. Use Editable text
- Validation. The hook doesn't validate; show errors on the field. Use Field
Examples#
Which fields changed
dirtyKeys and dirtyCount come from a diff against the baseline, not from edit events. Type a value back to what it was and it drops out of the count.
No changes. Type a value back to what it was and it drops out.
import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";import * as React from "react";export function DirtyKeys() { const id = React.useId(); const form = useDirtyForm({ terms: "Net 30", discount: "2%" }); const labels = { terms: "Payment terms", discount: "Early-pay discount" }; return ( <div className="flex w-full max-w-md flex-col gap-3"> <div className="grid gap-3 sm:grid-cols-2"> {(["terms", "discount"] as const).map((key) => ( <div key={key} className="flex flex-col gap-1.5"> <Label htmlFor={`${id}-${key}`}> {labels[key]} {form.dirtyKeys.includes(key) ? ( <span className="text-xs font-normal text-muted-foreground"> Edited </span> ) : null} </Label> <Input id={`${id}-${key}`} value={form.values[key]} onChange={(event) => form.set(key, event.target.value) } /> </div> ))} </div> <p className="rounded-[10px] bg-muted/70 px-3 py-2 text-13 text-muted-foreground tabular-nums"> {form.isDirty ? `${form.dirtyCount} changed: ${form.dirtyKeys.map((key) => labels[key]).join(", ")}` : "No changes. Type a value back to what it was and it drops out."} </p> </div> );}A failed save stays dirty
onSave throws, so save() resolves false, the baseline doesn't move and the save bar stays up. The next save succeeds.
import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { SaveBar } from "@oration/canon/components/save-bar";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";import * as React from "react";export function FailedSave() { const id = React.useId(); const [failNext, setFailNext] = React.useState(true); const form = useDirtyForm( { approvalLimit: "10000" }, { onSave: async () => { await new Promise((resolve) => setTimeout(resolve, 600)); if (failNext) { setFailNext(false); toast.add({ type: "error", title: "Couldn't save changes", description: "The server didn't respond. Your edits are still here.", }); throw new Error("Save failed"); } toast.add({ type: "success", title: "Approval limit saved" }); }, }, ); return ( <div className="flex w-full max-w-md flex-col gap-3"> <div className="flex flex-col gap-1.5"> <Label htmlFor={`${id}-limit`}> Approval needed above this amount, in USD </Label> <Input id={`${id}-limit`} inputMode="numeric" value={form.values.approvalLimit} onChange={(event) => form.set("approvalLimit", event.target.value) } className="tabular-nums" /> </div> <Switch label="Fail the next save" checked={failNext} onCheckedChange={setFailNext} className="px-0" /> <SaveBar dirty={form.isDirty} changes={form.dirtyCount} saving={form.saving} onDiscard={form.reset} onSave={() => void form.save()} className="mt-0" /> </div> );}Loading values with replace
replace() moves the values and the baseline together, so syncing from the ledger never shows up as an unsaved change.
import { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { toast } from "@oration/canon/components/toast";import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";import { RefreshCwIcon } from "lucide-react";import * as React from "react";export function ReplaceAfterLoading() { const id = React.useId(); const form = useDirtyForm({ remitTo: "PO Box 4410, Tacoma, WA 98401" }); return ( <div className="flex w-full max-w-md flex-col gap-3"> <div className="flex flex-col gap-1.5"> <Label htmlFor={`${id}-remit`}> Remit-to address for Northwind Freight </Label> <Input id={`${id}-remit`} value={form.values.remitTo} onChange={(event) => form.set("remitTo", event.target.value) } /> </div> <div className="flex flex-wrap items-center gap-2"> <Button type="button" variant="outline" size="sm" onClick={() => { form.replace({ remitTo: "1200 Harbor Way, Suite 300, Tacoma, WA 98421", }); toast.add({ title: "Synced from NetSuite", description: "The saved address was updated.", }); }} > <RefreshCwIcon data-icon="inline-start" aria-hidden="true" /> Sync from NetSuite </Button> <Button type="button" variant="ghost" size="sm" onClick={() => form.reset()} disabled={!form.isDirty} > Discard edits </Button> <span className="text-13 text-muted-foreground"> {form.isDirty ? "1 unsaved change" : "No unsaved changes"} </span> </div> </div> );}Diffing two snapshots
changedKeys on its own turns two versions of a record into an activity line. Arrays compare by value.
Tomás Ferreira changed payment terms and approvers on Northwind Freight
- Payment termsNet 30toNet 45
- ApproversPriya RamantoPriya Raman, Jordan Lee
Monday, Sep 28 at 10:42 AM
import { changedKeys } from "@oration/canon/hooks/use-dirty-form";export function DiffSnapshots() { const before = { terms: "Net 30", method: "ACH", remitTo: "PO Box 4410, Tacoma, WA 98401", approvers: ["Priya Raman"], }; const after = { terms: "Net 45", method: "ACH", remitTo: "PO Box 4410, Tacoma, WA 98401", approvers: ["Priya Raman", "Jordan Lee"], }; const labels = { terms: "Payment terms", method: "Payment method", remitTo: "Remit-to address", approvers: "Approvers", }; const show = (value: string | string[]) => Array.isArray(value) ? value.join(", ") : value; const changed = changedKeys(before, after); return ( <div className="flex w-full max-w-md flex-col gap-2 rounded-xl bg-card p-4 shadow-border"> <p className="text-13 text-foreground"> <span className="font-medium">Tomás Ferreira</span> changed{" "} {changed.map((key) => labels[key].toLowerCase()).join(" and ")}{" "} on Northwind Freight </p> <ul className="flex flex-col gap-1 rounded-[10px] bg-muted/70 px-3 py-2 text-13"> {changed.map((key) => ( <li key={key} className="flex flex-wrap gap-x-2 text-muted-foreground" > <span className="text-foreground">{labels[key]}</span> <span>{show(before[key])}</span> <span aria-hidden="true">→</span> <span className="sr-only">to</span> <span className="text-foreground"> {show(after[key])} </span> </li> ))} </ul> <p className="text-xs text-muted-foreground"> Monday, Sep 28 at 10:42 AM </p> </div> );}States#
| State | Treatment |
|---|---|
| Clean | values equal saved. isDirty is false and the save bar is hidden. |
| Dirty | One or more fields differ. dirtyKeys lists them and dirtyCount feeds the save bar's count. |
| Saving | saving is true while onSave runs. The save bar shows a spinner and disables Discard. |
| Saved | onSave resolved: the baseline becomes the values that were saved and the form is clean. |
| Save failed | onSave threw or rejected: save() returns false, the baseline stays and the form stays dirty. |
| Discarded | reset() puts values back to saved. |
Behavior#
initialis read once, on mount. To load or reload data later, callreplace(next), which sets both the values and the baseline so nothing reads as changed.set(key, value)updates one field and accepts an updater:set("approvers", (list) => [...list, "Jordan Lee"]).setValuesreplaces the whole object.- Dirty means different, not touched. Primitives compare with
Object.is; objects and arrays compare byJSON.stringify. save()snapshots the values when it starts. Edits made while saving stay dirty against the new baseline.onSaveis read from a ref, so it can close over fresh state without re-creating the hook.- It has no opinion about leaving the page or shortcuts;
SaveBaradds ⌘S (Ctrl+S) and the before-unload warning.
Do and don't#
dirtyCount to the save bar's changes, so editing a field back to its saved value takes it out of the count.onSave when the save fails, and toast what went wrong. The form stays dirty and the bar stays up.onSave. The baseline moves, the bar leaves, and nothing was saved.replace(data) after loading or syncing values.setValues(data) after loading, which marks every loaded field as an unsaved change.Content#
- The save bar counts changes: 1 unsaved change, 3 unsaved changes. Use
messageonly when the count would mislead. - Keep Save changes and Discard as the labels; name the object only when a page has two save bars.
- Confirm a save with a short success toast, Changes saved. On failure say what failed and keep the edits: Couldn't save changes with the reason.
Accessibility#
- The hook renders nothing.
SaveBaris a region named Unsaved changes whose count is a polite live region, so the change is announced without moving focus. - Don't move focus to the save bar when the form becomes dirty; people are still typing.
- Keep Save enabled while dirty and report problems after the attempt, rather than disabling it silently.
| Keys | Action |
|---|---|
| ⌘S | Saves while the save bar is showing (Ctrl+S on Windows and Linux). |
API reference#
useDirtyForm
useDirtyForm<T extends Record<string, unknown>>(initial: T, options?: DirtyFormOptions<T>), from @oration/canon/hooks/use-dirty-form. Also exports the types DirtyFormOptions<T> and DirtyForm<T>.
| Prop | Type | Default | Description |
|---|---|---|---|
initialRequired | T | No default | Starting values and baseline. Read once on mount. |
options.onSave | (values: T) => void | Promise<void> | No default | Runs on save with the snapshot. Throw or reject to keep the form dirty. |
Returns
DirtyForm<T>.
| Prop | Type | Default | Description |
|---|---|---|---|
values | T | No default | Current values. |
saved | T | No default | The last saved baseline. |
set | <K extends keyof T>(key: K, value: T[K] | ((current: T[K]) => T[K])) => void | No default | Update one field. |
setValues | Dispatch<SetStateAction<T>> | No default | Replace all values. The baseline doesn't move. |
reset | () => void | No default | Discard: values back to the baseline. |
replace | (next: T) => void | No default | Set values and baseline together, after loading data. |
save | () => Promise<boolean> | No default | Runs onSave; resolves true and moves the baseline on success, false on failure. |
saving | boolean | No default | True while onSave runs. |
dirtyKeys | Array<keyof T> | No default | Fields that differ from the baseline. |
dirtyCount | number | No default | dirtyKeys.length, for the save bar. |
isDirty | boolean | No default | True when any field differs. |
changedKeys
changedKeys<T>(saved: T, values: T): Array<keyof T>. The diff useDirtyForm uses, exported for snapshots of your own.
| Prop | Type | Default | Description |
|---|---|---|---|
savedRequired | T | No default | The baseline snapshot. |
valuesRequired | T | No default | The snapshot to compare. Keys present in either are checked. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Objects compare by JSON.stringify, so the same fields in a different key order read as changed, and Dates, Maps and Sets don't compare reliably. Keep form values to plain JSON.
A new initial after mount is ignored without warning; forgetting replace() leaves the form showing stale values.