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.
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
- Per-field opt-in: the declarative opt-in
form.persist()bypasses. - Edge cases & hydration: what happens when imperative writes race the debouncer, hydration timing, cross-tab.
handleSubmit: the success path that owns the auto-clear.