Build an autosave drawer that survives a slammed laptop lid
By the end of this recipe you'll have an edit drawer (bottom sheet on mobile) that saves each field 600 ms after you stop typing, shows a quiet "Saved 2s ago" status, recovers a draft if the drawer was closed mid-edit, and resolves the case where someone else updated the record while you were typing.
Overview
A drawer that opens to edit one row, autosaves per field on a 600 ms debounce, shows last-saved time, and never silently overwrites someone else's change. State machine: 'idle' | 'dirty' | 'saving' | 'saved' | 'conflict' | 'error'.
Admin-portal edits are the most-used surface in any operator app: payroll updates names, finance updates VAT numbers, HR updates emergency contacts. Demanding the user tap "Save" every single time turns a 30-second edit into a forgettable ritual — and the day they forget, the work is lost. Autosave makes the drawer feel like a Google Doc; closing it is the save.
The opinionated bit is one debounced commit per field, not one big debounce for the whole form. That way the user can edit name, save, then immediately move to email and the name's already on the server. We also stash an in-flight draft in sessionStorage so a closed drawer mid-edit recovers on reopen.
Skip this recipe if the form has destructive consequences — bank-account changes, role escalations. Those want an explicit "Save" with a confirm, not silent autosave.
What you'll build
Three states the user sees: typing, saved, and conflict.
Edit user
Saving…Name
Edit user
Saved 2s agoName
Edit user
Name
You can see this live in
Portal demos that wire this drawer in context. Open one to watch the autosave indicator fire.
Required pieces
Everything this recipe pulls from @corelithzw/react.
@corelithzw/react exports used here: Drawer, BottomSheet, Stack, Form, Field, Input, Select, Button, Alert, Toast. We also lean on a tiny useDebouncedSave hook — define it here, reuse it elsewhere.
Step-by-step build
State machine: 'idle' | 'dirty' | 'saving' | 'saved' | 'conflict' | 'error'
A boolean isSaving isn't enough — the user can be typing while a previous save is in flight, then a conflict comes back, and now the boolean lies to you. A string union forces every render path to handle every case, including the awkward "saved but the server bumped the version" one.
If the API rejects with 409, the saga lands on conflict.
CONFLICT_RESOLVE rewinds to dirty so the next debounce flushes the chosen value.
Switch to the Code tab to see the snippet.
Debounce per field with one shared timer (600 ms is the sweet spot)
200 ms feels twitchy; 1.5 s feels lossy if the user closes the tab. 600 ms is long enough to absorb a fast typist's pauses and short enough that the "Saved 2s ago" line is honest. One shared timer means consecutive edits across different fields coalesce into a single PUT, not five.
Status guard means a save in flight doesn't get re-fired by a stale dirty render.
Switch to the Code tab to see the snippet.
Detect conflicts with an If-Match version header, not by diffing JSON
The server is the source of truth. Send the serverVersion with every PUT; a 409 means someone else moved the record forward. Their body is the new ground truth — show it next to the user's value and let them choose. Never auto-merge field-by-field; that produces Frankensteins.
409 returns the current server state. We never overwrite blind.
User picks "keep mine" or "keep theirs"; the resolver re-PUTs with the new version.
Switch to the Code tab to see the snippet.
Stash a draft in sessionStorage so a closed drawer comes back
Operators close drawers by mistake all the time — escape key, back button on mobile, accidental click outside. Drop the current data into sessionStorage on every change. On reopen, if there's a draft and it differs from the server, ask the user once: "You had unsaved edits. Restore them?"
If the server already has the change (autosave succeeded), the draft and server match and we don't prompt.
Switch to the Code tab to see the snippet.
Render the drawer (desktop) or bottom sheet (mobile) — same form, two shells
The Drawer and BottomSheet primitives share the same children API. Pick one at a CSS breakpoint with a useMatchMedia hook, or just always render Drawer and let it morph internally — both ship from @corelithzw/react. The status pill is a single line in the drawer header; keep it deliberately understated.
Edit user
Saved 2s agoFinal composition
The whole EditUserDrawer, assembled. State machine, debounce, conflict resolution, draft recovery, drawer + bottom sheet.
Edit user
Saved 2s agoVariations
Three forks. Each a small diff off the final composition.
Explicit save (hybrid)
Autosave for soft fields (notes, tags), explicit save for the rest. Keep the debounce hook but only fire it for whitelisted keys.
const AUTOSAVE_KEYS = new Set(['notes', 'tags']);
// in reducer EDIT:
const onlyAuto = Object.keys(a.patch)
.every(k => AUTOSAVE_KEYS.has(k));
return { ...s, data: { ...s.data, ...a.patch },
status: onlyAuto ? 'dirty' : 'idle' };
// Show explicit "Save changes" for the rest
Last-write-wins
Low-stakes data where conflicts are rare. Drop conflict handling; the PUT always wins server-side. Saves one round-trip per edit.
// PUT without If-Match
await fetch(`/api/users/${id}`, {
method: 'PUT',
body: JSON.stringify(s.data),
});
// drop CONFLICT & CONFLICT_RESOLVE
// drop conflict alert block
Offline-first
Field stays editable while offline. Queue edits in IndexedDB; flush when the network returns. Status pill shows "Saved locally · syncing".
const online = useOnline();
useEffect(() => {
if (online && s.status === 'dirty') flushQueue();
}, [online, s.status]);
// Pill: status === 'dirty' && !online
// ? 'Saved locally' : standard pill
Accessibility
What this recipe takes care of for you.
- Status announcements are polite, not interruptive. The "Saving…" / "Saved" pill is wrapped in
aria-live="polite"so it never cuts off the user's typing — but conflict and error alerts userole="alert". - Drawer traps focus, returns it on close. Both
DrawerandBottomSheetmanage focus internally — Tab cycles inside, Escape closes, and focus returns to the row that opened the drawer. - The "Saved 2s ago" line is text, not an icon. A check-only icon would be invisible to a screen reader. A read-only string is.
- Conflict alert uses
role="alert". Interruptive on purpose — the user must not type more into a record whose ground truth has shifted. - Bottom sheet has a real drag handle. Keyboard users can close it with Escape; pointer users can drag it; screen readers get a labelled close button.
- Draft recovery is opt-in. We never silently overwrite the user's fresh view with a stale draft. Restore is one tap, Discard is another.