Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * The slice of the Web Storage API this hook actually touches. Typing the injected
 * storage this narrowly (rather than as `Storage`) means a test double, an in-memory
 * store or an encrypting wrapper is a three-method object literal.
 */
export interface FormPersistStorage {
  getItem(key: string): string | null
  setItem(key: string, value: string): void
  removeItem(key: string): void
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-form-persist.json

Prompt

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.

On This Page