Imperative persistence

Two methods, one job each. form.persist(path) flushes the current value at a path, form.clearPersistedDraft() wipes the backend entry. Both are async, both no-op cleanly when persistence isn't configured.

Category
Return methods
form.persist
(path, options?) => Promise<void>
form.clearPersistedDraft
(path?) => Promise<void>
Bypass per-field gate?
form.persist yes; clearPersistedDraft N/A.

Neither field in this demo opts into persistence via register, but form.persist(path) writes them anyway. That's the bypass: the method ignores the per-field opt-in gate so a "Save draft" button can capture whatever's on screen, including fields that don't otherwise persist. The clear buttons demonstrate the per-path and whole-form variants.

Imperative Demo Open in playground

Neither field opted into persistence via register. form.persist() bypasses that gate and writes the current snapshot anyway — useful for "Save draft" buttons and beforeunload handlers.

form.persist(path, options?)

await form.persist('email') // flush just one path's subtree
await form.persist('step1') // works on object subtrees too
await form.persist('password', { acknowledgeSensitive: true }) // sensitive paths

A one-shot read-merge-write that:

  • Bypasses the per-element opt-in gate. Use it when an explicit user action ("Save draft", beforeunload, wizard section boundary) means "save what's on screen now."
  • Bypasses the debouncer. Pending writes flush first.
  • Preserves untouched paths in storage. A path-scoped call merges into the existing envelope; it does not overwrite siblings.
  • Warns and no-ops on heuristic-matched paths unless { acknowledgeSensitive: true } is passed. Sensitive paths without acknowledgement are simply not written; the call resolves cleanly so a misconfig can't take down the app.
  • No-ops silently when persist: isn't configured on the form. Adding a "Save draft" button to a non-persisted form is a no-cost call.

For "save the whole form," iterate the top-level paths:

const paths = ['title', 'body', 'tags'] as const

async function onSaveDraft() {
  for (const p of paths) await form.persist(p)
}

// beforeunload guard for a long wizard
window.addEventListener('beforeunload', () => {
  for (const p of paths) void form.persist(p)
})

// Wizard step transition: only the current step's subtree
async function goToStep(n: number) {
  await form.persist(`step${currentStep.value}`)
  currentStep.value = n
}

The explicit-path signature is deliberate; "save what's on screen" is rarely literally the whole form. The path-scoped call gives you a precise checkpoint without accidentally promoting unfocused fields into storage.

form.clearPersistedDraft(path?)

await form.clearPersistedDraft() // wipe the whole envelope
await form.clearPersistedDraft('email') // wipe one path's slot

clearPersistedDraft does NOT touch in-memory form state, and does NOT disable any active opt-ins; future writes from opted-in bindings will re-populate the storage entry.

For "wipe both in-memory and on-disk," call reset() after clearPersistedDraft():

async function startFresh() {
  await form.clearPersistedDraft()
  form.reset()
}

Auto-clear on submit

By default, a successful submit fires clearPersistedDraft() automatically. handleSubmit's success callback resolving is the signal to drop the draft. Set clearOnSubmitSuccess: false on the form's persist config to opt out (review pages, retry-prone APIs that want to keep the draft until a confirmation lands):

useForm({
  schema,
  key: 'signup',
  persist: { storage: 'local', clearOnSubmitSuccess: false },
})

The default keeps the on-disk surface aligned with the user's mental model: "I submitted, the draft is done." Override only when there's a concrete reason to keep it.

Where to next