Saving and unsaved changes
When changes save instantly, when they wait for the save bar, and how leaving with unsaved work is handled.
The problem#
People need to know, without thinking about it, whether a change is already live, waiting to be saved, or lost when they leave.
A settings page where some switches apply at once and others wait for a button teaches nobody anything. A long form that autosaves silently leaves people unsure whether a half-typed threshold just changed tomorrow's payment run. A procedure that Nora follows on live calls can't change the moment someone fixes a typo. And a reload or a stray click on the sidebar can throw away ten minutes of work.
The solution in Canon#
Each surface picks one save model and shows it. Small reversible changes apply instantly with Undo; related fields wait for the save bar; anything live goes through a draft and a publish.
One save model per surface
Instant changes offer Undo
Live things are published
Decision guide#
Choose by what the change touches and how easily it is reversed.
| The change | Model | Cedarline example |
|---|---|---|
| One on or off setting, easy to reverse | Instant: switch plus a toast with Undo | Take early-pay discounts |
| A field on a record: status, owner, tag | Instant, inline | Assign INV-20417 to Priya Raman |
| Several related fields that only make sense together | Explicit: useDirtyForm and the Save bar | Payment run defaults |
| Creating a record | A form with a submit button | New supplier |
| Text that something live follows | Draft autosaves, publish creates a version | Nora's Where is my payment procedure |
| An action on a row in a list | Optimistic: update now, roll back on failure | Approve an invoice |
Instant apply#
Switches and inline record fields save on change. The control moves, a toast confirms, and Undo puts it back.
import { descriptionId, SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function InstantApply() { type Key = "autoMatch" | "earlyPay" | "notify"; const id = React.useId(); const settings: { key: Key; label: string; description: string; on: string; off: string; }[] = [ { key: "autoMatch", label: "Match invoices automatically", description: "Three-way match against the PO and receipt as invoices arrive.", on: "Automatic matching turned on", off: "Automatic matching turned off", }, { key: "earlyPay", label: "Take early-pay discounts", description: "Pay ahead of the due date when the discount beats 6% a year.", on: "Early-pay discounts turned on", off: "Early-pay discounts turned off", }, { key: "notify", label: "Email suppliers when a payment is sent", description: "Remittance advice goes to each supplier's remit-to email.", on: "Payment emails turned on", off: "Payment emails turned off", }, ]; const [values, setValues] = React.useState<Record<Key, boolean>>({ autoMatch: true, earlyPay: false, notify: true, }); const change = (key: Key, checked: boolean) => { setValues((current) => ({ ...current, [key]: checked })); const setting = settings.find((item) => item.key === key); toast.add({ title: checked ? setting?.on : setting?.off, actionProps: { children: "Undo", onClick: () => setValues((current) => ({ ...current, [key]: !checked })), }, }); }; return ( <div className="w-full max-w-2xl text-left"> <SettingsGroup> {settings.map((setting) => ( <SettingsRow key={setting.key} inline label={setting.label} htmlFor={`${id}-${setting.key}`} description={setting.description} > <Switch id={`${id}-${setting.key}`} checked={values[setting.key]} onCheckedChange={(checked) => change(setting.key, checked) } aria-describedby={descriptionId( `${id}-${setting.key}`, )} /> </SettingsRow> ))} </SettingsGroup> </div> );}- Use it when each control stands alone and its effect is easy to reverse.
- The toast says what is now true (Early-pay discounts turned on), not that something was saved.
- Undo restores the previous value without a second toast; the switch moving back is the feedback.
- If a change can't be undone, or takes effect on money already in motion, it isn't an instant change. Use a confirmation. See Destructive actions.
Explicit save#
When fields depend on each other, collect the changes and let the person commit them together. useDirtyForm tracks what changed against a saved baseline; SaveBar shows it.
Payment run defaults
Change any setting. The save bar rises with the count, Discard returns to the baseline, and Save or ⌘S commits.
Payment run defaults
Used for every scheduled run unless you change it on the run.
import { Input } from "@oration/canon/components/input";import { SaveBar } from "@oration/canon/components/save-bar";import { SelectField } from "@oration/canon/components/select-field";import { descriptionId, SettingsGroup, SettingsRow, SettingsSection } from "@oration/canon/components/settings-section";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 ExplicitSave() { const id = React.useId(); const form = useDirtyForm( { runDay: "thursday", threshold: "25000", approver: "maya", holdWithoutW9: true, }, { onSave: async () => { await new Promise((resolve) => setTimeout(resolve, 800)); toast.add({ type: "success", title: "Payment run defaults saved", description: "They apply from the run on Thursday, October 1.", }); }, }, ); return ( <div className="w-full max-w-2xl text-left"> <SettingsSection title="Payment run defaults" description="Used for every scheduled run unless you change it on the run." > <SettingsGroup> <SettingsRow label="Run day" htmlFor={`${id}-day`} description="Invoices approved by noon go out that day." > <SelectField id={`${id}-day`} value={form.values.runDay} onValueChange={(value) => form.set("runDay", value)} options={[ { value: "tuesday", label: "Tuesday" }, { value: "thursday", label: "Thursday" }, { value: "both", label: "Tuesday and Thursday", }, ]} describedBy={descriptionId(`${id}-day`)} className="w-full sm:w-56" /> </SettingsRow> <SettingsRow label="Second approval above" htmlFor={`${id}-threshold`} description="Runs with an invoice over this amount, in USD, need a second approver." > <Input id={`${id}-threshold`} inputMode="decimal" value={form.values.threshold} onChange={(event) => form.set("threshold", event.target.value) } aria-describedby={descriptionId(`${id}-threshold`)} className="tabular-nums sm:w-56" /> </SettingsRow> <SettingsRow label="Second approver" htmlFor={`${id}-approver`} > <SelectField id={`${id}-approver`} value={form.values.approver} onValueChange={(value) => form.set("approver", value) } options={[ { value: "maya", label: "Maya Okafor" }, { value: "priya", label: "Priya Raman" }, { value: "wen", label: "Wen Zhou" }, ]} className="w-full sm:w-56" /> </SettingsRow> <SettingsRow inline label="Hold invoices without a W-9" htmlFor={`${id}-hold`} description="Held invoices skip the run until the supplier uploads one." > <Switch id={`${id}-hold`} checked={form.values.holdWithoutW9} onCheckedChange={(checked) => form.set("holdWithoutW9", checked) } aria-describedby={descriptionId(`${id}-hold`)} /> </SettingsRow> </SettingsGroup> </SettingsSection> <SaveBar dirty={form.isDirty} changes={form.dirtyCount} saving={form.saving} onDiscard={form.reset} onSave={() => void form.save()} /> </div> );}| Part | Behavior |
|---|---|
| Appearance | Springs up from 12px below at 0.98 scale on spring.slow when the form turns dirty, and leaves on exit.slow (160ms). Sticky 16px above the bottom of the page column. |
| Message | An indigo unsaved dot and the count: 1 unsaved change, 3 unsaved changes. Undo a change by hand and the count drops; reach zero and the bar leaves. |
| Actions | Discard is ghost. Save changes is the view's one filled button, with the ⌘S hint. |
| Saving | Both buttons disable and a spinner shows until onSave settles. Throw from onSave to keep the form dirty, and toast the failure yourself. |
| Leaving | warnOnLeave asks before the tab closes or reloads while dirty. In-app navigation is yours to guard; see below. |
| Keys | Action |
|---|---|
| ⌘S | Saves while the bar is showing. Ctrl S on Windows and Linux. |
Drafts and publishing#
For what Nora and the workflows run on, editing never touches the live version. The draft autosaves as you type; publishing makes it the next version.
A versioned procedure
Edit the step. The draft saves itself and the status changes to Draft, not live. Publish asks first, then adds version 5 to the history.
Where is my payment
Procedure Nora follows on supplier calls
- v4Sep 21, Priya RamanLive
- v3Sep 9, Priya Raman
- v2Aug 30, Tomás Ferreira
import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { Label } from "@oration/canon/components/label";import { Spinner } from "@oration/canon/components/spinner";import { StatusLabel } from "@oration/canon/components/status-dot";import { Textarea } from "@oration/canon/components/textarea";import { toast } from "@oration/canon/components/toast";import { CheckIcon } from "lucide-react";import * as React from "react";export function DraftAndPublish() { type Version = { n: number; at: string; by: string }; const id = React.useId(); const published = "Ask for the invoice number or PO. Look up the payment, then read back the amount, the payment method and the date it was sent or is scheduled."; const [live, setLive] = React.useState(published); const [draft, setDraft] = React.useState(published); const [saving, setSaving] = React.useState(false); const [savedAt, setSavedAt] = React.useState<string | null>(null); const [confirm, setConfirm] = React.useState(false); const [versions, setVersions] = React.useState<Version[]>([ { n: 4, at: "Sep 21", by: "Priya Raman" }, { n: 3, at: "Sep 9", by: "Priya Raman" }, { n: 2, at: "Aug 30", by: "Tomás Ferreira" }, ]); const hasDraft = draft !== live; const current = versions[0]?.n ?? 1; React.useEffect(() => { if (draft === live) return; setSaving(true); const timer = window.setTimeout(() => { setSaving(false); setSavedAt("just now"); }, 700); return () => window.clearTimeout(timer); }, [draft, live]); return ( <div className="flex w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-card text-left shadow-border"> <div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border px-4 py-3"> <div className="min-w-0 flex-1"> <h3 className="truncate text-sm font-semibold"> Where is my payment </h3> <p className="text-xs text-muted-foreground"> Procedure Nora follows on supplier calls </p> </div> {hasDraft ? ( <StatusLabel tone="warning" className="text-muted-foreground" > Draft, not live </StatusLabel> ) : ( <StatusLabel tone="success" className="text-muted-foreground" > Version {current} is live </StatusLabel> )} </div> <div className="flex flex-col gap-2 px-4 py-4"> <div className="flex items-center justify-between gap-3"> <Label htmlFor={id}>Step 2: Look up the payment</Label> <span aria-live="polite" className="flex items-center gap-1.5 text-xs text-muted-foreground" > {hasDraft ? ( saving ? ( <> <Spinner className="size-3" /> Saving draft </> ) : savedAt ? ( <> <CheckIcon aria-hidden="true" className="size-3" /> Draft saved {savedAt} </> ) : null ) : null} </span> </div> <Textarea id={id} value={draft} onChange={(event) => setDraft(event.target.value)} className="min-h-20" /> </div> <div className="flex items-center justify-end gap-2 border-t border-border bg-muted/50 px-4 py-3"> <Button variant="ghost" disabled={!hasDraft} onClick={() => { setDraft(live); toast.add({ title: "Draft discarded", description: `Version ${current} is unchanged.`, }); }} > Discard draft </Button> <Button disabled={!hasDraft || saving} onClick={() => setConfirm(true)} > Publish version {current + 1} </Button> </div> <ul className="border-t border-border px-2 py-1"> {versions.map((version, index) => ( <li key={version.n} className="flex min-h-11 items-center gap-3 rounded-lg px-2 py-1.5" > <span className="w-8 text-13 font-medium tabular-nums"> v{version.n} </span> <span className="min-w-0 flex-1 truncate text-13 text-muted-foreground"> {version.at}, {version.by} </span> {index === 0 ? ( <span className="text-xs text-muted-foreground"> Live </span> ) : ( <Button variant="ghost" size="sm" onClick={() => toast.add({ title: `Version ${version.n} opened as a draft`, description: "Publish it to make it live again.", }) } > Restore </Button> )} </li> ))} </ul> <ConfirmDialog open={confirm} onOpenChange={setConfirm} destructive={false} title={`Publish version ${current + 1}?`} description={`Nora follows it from the next supplier call. Version ${current} stays in the history and can be restored.`} confirmLabel={`Publish version ${current + 1}`} onConfirm={async () => { await new Promise((resolve) => setTimeout(resolve, 600)); setLive(draft); setSavedAt(null); setVersions((list) => [ { n: current + 1, at: "Sep 28", by: "Maya Okafor" }, ...list, ]); toast.add({ type: "success", title: `Version ${current + 1} published`, description: "Nora uses it from the next call.", }); }} /> </div> );}- Show which version is live and whether a draft exists, as a status label beside the title.
- Autosave the draft quietly: Saving draft with a small spinner, then Draft saved just now. No toast for autosave.
- Publishing is the view's one filled button, labelled with the version it creates. Confirm it with a plain (not red) Confirm dialog that says who is affected and when.
- Keep every published version. Restoring an old one opens it as a draft; it goes live only when published again.
- Discard draft returns to the live version and says so in a toast.
Leaving with unsaved changes#
Unsaved work is never thrown away without asking. The save bar covers closing the tab; moving elsewhere inside the app needs a guard you add.
Switching sections while dirty
Change a setting in Approvals, then pick another section. The dialog offers to keep editing or discard.
import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { Input } from "@oration/canon/components/input";import { SaveBar } from "@oration/canon/components/save-bar";import { SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import { SideTabs } from "@oration/canon/components/side-tabs";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 LeaveGuard() { type Section = "approvals" | "remittance" | "notifications"; const id = React.useId(); const panelId = `${id}-panel`; const [section, setSection] = React.useState<Section>("approvals"); const [pending, setPending] = React.useState<Section | null>(null); const form = useDirtyForm( { threshold: "25000", dualApproval: true, replyTo: "remittance@cedarline.com", digest: false, }, { onSave: async () => { await new Promise((resolve) => setTimeout(resolve, 600)); toast.add({ type: "success", title: "Settings saved" }); }, }, ); const go = (next: string) => { const target = next as Section; if (target === section) return; if (form.isDirty) setPending(target); else setSection(target); }; return ( <div className="flex w-full max-w-3xl flex-col gap-4 text-left md:flex-row"> <SideTabs label="Payables settings" orientation="vertical" panelId={panelId} value={section} onValueChange={go} className="md:w-48" items={[ { id: "approvals", label: "Approvals" }, { id: "remittance", label: "Remittance" }, { id: "notifications", label: "Notifications" }, ]} /> <div id={panelId} role="tabpanel" aria-labelledby={`side-tab-${section}`} className="min-w-0 flex-1" > <SettingsGroup> {section === "approvals" ? ( <> <SettingsRow label="Second approval above" htmlFor={`${id}-t`} > <Input id={`${id}-t`} inputMode="decimal" value={form.values.threshold} onChange={(event) => form.set( "threshold", event.target.value, ) } className="tabular-nums sm:w-40" /> </SettingsRow> <SettingsRow inline label="Require two approvers on new suppliers" htmlFor={`${id}-d`} > <Switch id={`${id}-d`} checked={form.values.dualApproval} onCheckedChange={(checked) => form.set("dualApproval", checked) } /> </SettingsRow> </> ) : section === "remittance" ? ( <SettingsRow label="Reply-to address" htmlFor={`${id}-r`} > <Input id={`${id}-r`} type="email" value={form.values.replyTo} onChange={(event) => form.set("replyTo", event.target.value) } className="sm:w-60" /> </SettingsRow> ) : ( <SettingsRow inline label="Daily digest of held invoices" htmlFor={`${id}-g`} > <Switch id={`${id}-g`} checked={form.values.digest} onCheckedChange={(checked) => form.set("digest", checked) } /> </SettingsRow> )} </SettingsGroup> <SaveBar dirty={form.isDirty} changes={form.dirtyCount} saving={form.saving} shortcut={false} onDiscard={form.reset} onSave={() => void form.save()} /> </div> <ConfirmDialog open={pending !== null} onOpenChange={(open) => { if (!open) setPending(null); }} title="Discard unsaved changes?" description={`You changed ${form.dirtyCount === 1 ? "1 setting" : `${form.dirtyCount} settings`}. Leaving this section discards ${form.dirtyCount === 1 ? "it" : "them"}.`} cancelLabel="Keep editing" confirmLabel="Discard changes" onConfirm={() => { form.reset(); if (pending) setSection(pending); }} /> </div> );}| Way out | What happens |
|---|---|
| Close or reload the tab | The browser's own prompt, from SaveBar warnOnLeave. |
| Another section, tab or link in the app | A Confirm dialog: Discard unsaved changes?, with Keep editing and a red-tint Discard changes. |
| Close a dialog or sheet with edits | The same confirmation. A pristine dialog closes without asking. |
| Press Discard | Returns to the baseline at once, no confirmation. The person asked for it. |
Known gap
warnOnLeave only covers closing or reloading the tab. Following an in-app link with unsaved edits loses them without asking, so guard section switches and links yourself, as this example does. Recorded on Save bar.
Optimistic updates#
For row actions that almost always succeed, show the result at once and reconcile when the request returns.
Approve, then reconcile
Approve an invoice and the row changes immediately. Turn on Fail the next request to see it roll back with an error toast and Try again.
Waiting for approval
- $18,240.00
Northwind Freight
INV-20417
- $7,305.18
Orchard Street Foods
INV-20438
- $1,264.00
Pinecrest Supply
INV-20442
import { Button } from "@oration/canon/components/button";import { StatusLabel } from "@oration/canon/components/status-dot";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function OptimisticUpdate() { type Row = { id: string; supplier: string; amount: string; approved: boolean; }; const failId = React.useId(); const [failNext, setFailNext] = React.useState(false); const failRef = React.useRef(false); const [busy, setBusy] = React.useState<string[]>([]); const [rows, setRows] = React.useState<Row[]>([ { id: "INV-20417", supplier: "Northwind Freight", amount: "$18,240.00", approved: false, }, { id: "INV-20438", supplier: "Orchard Street Foods", amount: "$7,305.18", approved: false, }, { id: "INV-20442", supplier: "Pinecrest Supply", amount: "$1,264.00", approved: false, }, ]); const setApproved = (rowId: string, approved: boolean) => setRows((list) => list.map((row) => (row.id === rowId ? { ...row, approved } : row)), ); const approve = (row: Row) => { const shouldFail = failRef.current; failRef.current = false; setFailNext(false); setApproved(row.id, true); setBusy((list) => [...list, row.id]); window.setTimeout(() => { setBusy((list) => list.filter((item) => item !== row.id)); if (!shouldFail) return; setApproved(row.id, false); toast.add({ type: "error", title: `Couldn't approve ${row.id}`, description: `${row.supplier} is back in the queue. Nothing was sent.`, actionProps: { children: "Try again", onClick: () => approve(row), }, }); }, 1000); }; return ( <div className="flex w-full max-w-xl flex-col gap-3 text-left"> <label htmlFor={failId} className="flex items-center gap-2 text-13 text-muted-foreground" > <Switch id={failId} checked={failNext} onCheckedChange={(checked) => { failRef.current = checked; setFailNext(checked); }} /> Fail the next request </label> <section className="overflow-hidden rounded-xl bg-card shadow-border"> <h3 className="border-b border-border px-4 py-3 text-sm font-semibold"> Waiting for approval </h3> <ul> {rows.map((row) => ( <li key={row.id} className={cn( "flex min-h-13 items-center gap-3 border-b border-border px-4 py-2 transition-colors duration-150 last:border-b-0", row.approved && "bg-muted/40", )} > <div className="min-w-0 flex-1"> <p className="truncate text-13 font-medium"> {row.supplier} </p> <p className="font-mono text-xs text-muted-foreground"> {row.id} </p> </div> <span className="w-24 text-right text-13 font-medium tabular-nums"> {row.amount} </span> <div className="flex w-32 justify-end"> {row.approved ? ( <StatusLabel tone="success" className="text-muted-foreground" > Approved {busy.includes(row.id) ? ( <span className="sr-only"> , saving </span> ) : null} </StatusLabel> ) : ( <Button variant="outline" size="sm" onClick={() => approve(row)} > Approve </Button> )} </div> </li> ))} </ul> </section> </div> );}- Change the row, the count and any total in the same frame as the click.
- On success, say nothing more. The row already changed.
- On failure, put the row back exactly as it was and raise an error toast that names the record and offers Try again.
- Don't be optimistic about money leaving the account, publishing, or anything a person outside Cedarline will see. Wait for the answer with a Pending button.
Do and don't#
Accessibility#
Save state has to be heard as well as seen.
- The save bar is a labelled region (Unsaved changes) and its count is a polite live region, so changes to the count are announced.
- Save carries
aria-keyshortcutsfor ⌘S or Ctrl S. - Autosave status (Saving draft, Draft saved just now) sits in a polite live region beside the field it describes.
- Toasts for instant changes are announced politely; their Undo is a real button, reachable from the keyboard.
- The leave confirmation is an alert dialog with focus on Keep editing, the safe choice.
- An optimistic row that rolls back is announced through the error toast, which names the record.
The save bar's live region mounts with its text already inside, so most screen readers announce later count changes but not the bar's arrival. The gap is recorded on Save bar.
Components involved#
The parts this pattern is built from.
| Component | Role here |
|---|---|
| Save bar | The sticky unsaved-changes bar with Discard, Save and ⌘S. |
| Dirty form | useDirtyForm(initial, { onSave }): values, baseline, dirtyCount, reset, save. |
| Settings section | Sections, groups and label-left rows. |
| Switch | Instant on and off settings. |
| Toast | Confirms instant changes with Undo, reports failures. |
| Confirm dialog | Publishing, and discarding unsaved changes. |
| Side tabs | Settings sections that guard against leaving dirty. |
| Pending button | Waits for the answer when optimism isn't safe. |
| Status label | Live version against draft. |