Skip to content

Dirty form

Tracks which fields changed from their initial values and powers the save bar.

Status
Stable
Level
Utility
Category
Utilities
Adoption
Not used yet
import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";
packages/canon/src/hooks/use-dirty-form.ts
Email remittance advice to suppliers
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.

Fail the next save
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.

No unsaved changes
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#

States
StateTreatment
Cleanvalues equal saved. isDirty is false and the save bar is hidden.
DirtyOne or more fields differ. dirtyKeys lists them and dirtyCount feeds the save bar's count.
Savingsaving is true while onSave runs. The save bar shows a spinner and disables Discard.
SavedonSave resolved: the baseline becomes the values that were saved and the form is clean.
Save failedonSave threw or rejected: save() returns false, the baseline stays and the form stays dirty.
Discardedreset() puts values back to saved.

Behavior#

  • initial is read once, on mount. To load or reload data later, call replace(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"]). setValues replaces the whole object.
  • Dirty means different, not touched. Primitives compare with Object.is; objects and arrays compare by JSON.stringify.
  • save() snapshots the values when it starts. Edits made while saving stay dirty against the new baseline.
  • onSave is 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; SaveBar adds ⌘S (Ctrl+S) and the before-unload warning.

Do and don't#

Do. Pass dirtyCount to the save bar's changes, so editing a field back to its saved value takes it out of the count.
Don't. Track a touched flag and show Unsaved changes after the person has undone their edit.
Do. Throw or reject from onSave when the save fails, and toast what went wrong. The form stays dirty and the bar stays up.
Don't. Catch the error inside onSave. The baseline moves, the bar leaves, and nothing was saved.
Do. Call replace(data) after loading or syncing values.
Don't. Call 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 message only 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. SaveBar is 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.
Keyboard interactions
KeysAction
⌘SSaves 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>.

Props of useDirtyForm
PropTypeDefaultDescription
initialRequiredTNo defaultStarting values and baseline. Read once on mount.
options.onSave(values: T) => void | Promise<void>No defaultRuns on save with the snapshot. Throw or reject to keep the form dirty.

Returns

DirtyForm<T>.

Props of Returns
PropTypeDefaultDescription
valuesTNo defaultCurrent values.
savedTNo defaultThe last saved baseline.
set<K extends keyof T>(key: K, value: T[K] | ((current: T[K]) => T[K])) => voidNo defaultUpdate one field.
setValuesDispatch<SetStateAction<T>>No defaultReplace all values. The baseline doesn't move.
reset() => voidNo defaultDiscard: values back to the baseline.
replace(next: T) => voidNo defaultSet values and baseline together, after loading data.
save() => Promise<boolean>No defaultRuns onSave; resolves true and moves the baseline on success, false on failure.
savingbooleanNo defaultTrue while onSave runs.
dirtyKeysArray<keyof T>No defaultFields that differ from the baseline.
dirtyCountnumberNo defaultdirtyKeys.length, for the save bar.
isDirtybooleanNo defaultTrue when any field differs.

changedKeys

changedKeys<T>(saved: T, values: T): Array<keyof T>. The diff useDirtyForm uses, exported for snapshots of your own.

Props of changedKeys
PropTypeDefaultDescription
savedRequiredTNo defaultThe baseline snapshot.
valuesRequiredTNo defaultThe 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.