useFormPersist
A form-draft hook: debounced writes into a versioned storage envelope, an explicit restore offer at mount, and a one-call clear after a successful submit.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-form-persist.jsonPrompt
Build a React + TypeScript "useFormPersist" hook (React only — no third-party
dependencies; the browser Web Storage API and queueMicrotask are the only
platform APIs).
Contract
- useFormPersist<T>(options: UseFormPersistOptions<T>): UseFormPersistResult<T>
- Options:
- key: string — storage key. Changing it opens a new session: the new key is
read and gets its own baseline.
- values: T — the current form values, owned by the caller (useState,
react-hook-form's watch(), a reducer). Must be JSON-serializable.
- version = 1 — form-shape version, stamped into every payload.
- debounceMs = 600 — quiet window before a write lands. Changing it restarts
the wait.
- storage = () => localStorage — a LAZY factory returning
{ getItem, setItem, removeItem } or null. A factory, not a store: touching
window during module evaluation breaks SSR, and Safari's private mode throws
on the property access itself. Return null to disable persistence.
- validate?: (values: unknown) => T | null — the last gate before a stored
draft is trusted; return null to reject it. The natural home for
schema.safeParse(v).data ?? null.
- now = Date.now — injected clock for savedAt, called only when a write
commits. Never read a clock during render.
- Result: { draft, draftState, saveState, lastSavedAt, restore, discard, clear,
flush }
- draft: { values: T; savedAt: number; version: number } | null — what the
mount-time read found, still un-applied.
- draftState: "empty" | "found" | "restored" | "discarded" |
"version-mismatch" | "invalid" | "unavailable" — the restore signal. Every
branch is something a user can be told.
- saveState: "idle" | "pending" | "saved" | "unavailable".
- lastSavedAt: number | null — the instant of the newest draft in storage.
- restore(): T | null — hands the draft over ONCE, then returns null forever.
- discard(): void — delete the draft and stop offering it.
- clear(): void — post-submit reset: cancel any owed write, delete the key,
return both machines to zero.
- flush(): void — commit an owed write now instead of waiting out the debounce.
- The hook never writes into the caller's state. It reports; the caller applies.
Auto-hydration is the wrong default: it replaces what the user is looking at
and leaves no moment at which to tell them.
Behavior
- Stored payload is an envelope, not the bare values:
{ version, savedAt, values }. Version and instant have to travel WITH the
draft — a sibling key holding the version is a second thing to keep in sync
and a second thing to leave behind.
- Content-keyed debounce (the load-bearing bit). Serialize values during render
with JSON.stringify and run the write effect on that STRING. Form libraries
hand back a fresh object every render; keying the timer off object identity
restarts the debounce on every unrelated re-render, and a form that re-renders
faster than debounceMs would never save at all. Serializing in render is pure
and cheap for draft-sized data.
- Baseline rule: a draft is a DIFF from the values the session mounted with.
- Opening a session records the baseline snapshot and disarms the writer; the
first write-effect run of that session only re-arms it and returns. Writing
there would overwrite the very draft the read is about to find.
- snapshot === baseline => delete the key immediately (no debounce; there is
nothing to schedule about a removal) and report saveState "idle". An erased
form is not an empty draft, it is no draft — and this is also what stops the
post-submit reset to defaults from resurrecting the entry clear() just
deleted.
- snapshot !== baseline => (re)start the debounce, saveState "pending"; when
it fires, write and report "saved" plus lastSavedAt.
- Mount / session read, in order: absent key => "empty"; JSON.parse throws or
the envelope lacks a numeric version / numeric savedAt / a values key =>
delete, "invalid"; envelope.version !== version => delete,
"version-mismatch"; validate returns null => delete, "invalid"; otherwise
"found" with the draft. Rejected payloads are DELETED, never parked: a draft
that cannot be used must not be re-offered on the next load.
- Both effects publish from a queueMicrotask, never from the effect body: a
synchronous setState in an effect cascades an extra render pass (and trips
react-hooks/set-state-in-effect). A cancelled flag set in the cleanup means
StrictMode's mount → cleanup → mount publishes exactly once, and a run that a
later keystroke has already superseded publishes nothing. Microtasks drain
ahead of any timer, so "pending" always reaches the screen before "saved",
even with debounceMs: 0.
- The offer outlives the user typing: draftState stays "found" until restore()
or discard() is called, while the debounced writer happily overwrites the
stored copy with what is being typed now. The hook cannot decide whether a
later edit means "I do not want that draft" — if it should mean that in your
UI, hide the banner (or call discard()) on the first change.
- One-shot restore: the draft lives in a ref as well as in state; restore()
reads the ref and clears it in the same tick, so a double click hands the
payload over once and returns null the second time instead of stamping the
draft over edits made in between.
- The owed write survives a debounce restart. The effect cleanup clears the
TIMER but keeps the pending commit closure; a mount-only effect flushes it on
unmount, so navigating away mid-window does not cost the user their last
keystrokes. Each commit nulls the closure first, so it can never fire twice.
- The commit closure captures key, version and payload at SCHEDULE time. A key
change mid-debounce must flush the previous form's draft under the PREVIOUS
key, not stamp it onto the new one.
- Options that callers write as inline arrows (storage, validate, now) live in a
latest-ref synced in useInsertionEffect, never in dependency arrays — a new
identity every render would restart the debounce forever. Every returned
callback is useCallback([])-stable and safe in a dependency array.
- Degradation, never a throw: a storage factory that returns null or throws, a
getItem that throws, a setItem that hits quota, a removeItem refused after the
permission was revoked mid-session — all resolve to "unavailable" and the form
keeps working. The delete helper reports success as a boolean and the CALLER
decides what to publish, which is what keeps discard() from claiming a draft
is gone while it still sits in storage waiting to be re-offered. Values
JSON.stringify cannot handle (circular, BigInt, a bare undefined) are skipped
with a one-time development warning.
- SSR: the first paint renders draftState "empty" / saveState "idle" on both
sides; the real answer arrives in a microtask after mount, so there is no
hydration mismatch and no typeof window guard in the render body.
Rendering & styling
- The hook renders nothing — it returns state plus four stable callbacks. For
the UI around it:
- Restore offer: a banner rendered only while draftState === "found", inside
role="status" so it is announced when it appears rather than silently
changing the page. Say WHEN the draft is from (format the instant with
toISOString, not toLocaleTimeString, if the markup is server-rendered) and
make clear that nothing has been applied yet.
- Save indicator: a badge in a role="status" live region — "Saving…" for
pending, "Draft saved HH:MM" for saved, an explicit "No storage" for
unavailable. Do not promise a draft that can never be written.
- Focus: Restore and Discard unmount the banner they live in, so move focus
to a deliberate successor in the same handler — the first field the draft
is about — never to the document body.
- Keyboard: the banner is not a dialog, so it needs no focus trap and no
Escape handler; place it before the fields in DOM order so Tab reaches
Restore / Discard before the inputs. Enter and Space activate them because
they are real buttons. If you bind Ctrl/Cmd+S to flush(), preventDefault()
the browser's save dialog.
- A control the user may be focused on (a Submit that is inert while the form
is empty) gets aria-disabled plus a handler guard, not the native disabled
attribute — do not remove a focused control from the tab order.
- Semantic tokens only: bg-card / border for the form surface, bg-muted for
readouts, border-primary/40 + bg-primary/5 for the restore offer,
text-muted-foreground for hints, focus-visible:ring-3 ring-ring on every
control. Any spinner is animate-spin motion-reduce:animate-none — with
motion off the indicator still reads correctly.
Customization levers
- Storage medium — () => sessionStorage for a per-tab draft that dies with the
tab, () => null behind a consent flag, or a three-method object literal
wrapping IndexedDB / an encrypting codec for drafts that must not sit in
plaintext.
- Trust gate — validate is where zod goes: schema.safeParse(v).data ?? null.
Without it, version alone decides, which is enough when you control every
writer and not enough when users can hand-edit storage.
- Versioning policy — bump version on any field rename or type change. To
migrate instead of discard, add a migrate(oldVersion, values) step in front of
validate and return the upgraded values.
- Debounce feel — 300ms for short forms, 1500ms+ for large payloads or
serialization-heavy state; pair with flush() bound to blur, route change or
pagehide for "never lose more than one field".
- Scope of the key — one key per form is the simple case; suffix it with the
record id (draft:invoice:42) for per-record drafts, and the hook's key-change
path will flush the old one and read the new one.
- Baseline meaning — mounting with server values instead of empty ones makes the
draft a diff from the saved record, so "no changes" correctly stores nothing.
- What to persist — feed the hook a subset of the form (omit passwords,
one-time codes and file inputs) rather than the whole object; the contract is
unchanged, the payload is smaller and less sensitive.Concepts
- Restore handshake — the draft is offered, not applied. The hook reports
draftState: "found"and waits;restore()hands the values over exactly once (a ref read and cleared in the same tick), so a second click cannot stamp an old draft over what the user has since typed. Auto-hydration has no equivalent moment at which to tell the user anything happened. - Content-keyed debounce — the values are serialized during render and the write pipeline runs on that string. Keying it off object identity instead would restart the timer on every unrelated re-render, and a form that re-renders faster than the quiet window would never save; keying it off content means an equal-but-new object is correctly a no-op.
- Baseline diff — a draft is defined as a difference from the values the session mounted with. The first run of a session only records that baseline, and coming back to it deletes the key instead of storing an empty draft — which is also what keeps a post-submit reset from resurrecting the entry
clear()just removed. - Versioned envelope — version and instant travel inside the payload, so a form whose shape changed discards the old draft on the spot rather than hydrating fields that no longer exist. A rejected payload is deleted, never parked, so the next load cannot be offered the same garbage.
- Owed write — a debounce restart drops the timer but keeps the intent, so an unmount in the middle of the quiet window still commits the last keystrokes, exactly once.
flush()is the same commit, triggered by hand. - Degrading to unavailable — private mode, blocked site data and an exceeded quota are reported as a state rather than thrown as an error; the form stays usable and the UI can tell the user their work will not survive a reload.
useVibrate
A Vibration API hook that plays a normalized haptic pattern from a user gesture, names every refusal instead of failing silently, honours a page-wide haptics mute, and cancels the pattern it owns on unmount.
useMutationQueue
A durable offline write queue: enqueue with an optimistic id that doubles as an idempotency key, persist it so a reload keeps the work, drain in order when the connection returns, and park a poisoned write instead of letting it block the rest.