Persist drafts across reloads
Long forms — multi-step onboarding, checkout, surveys — should survive a navigation mistake or a browser refresh. Attaform persists drafts to client-side storage with a per-field opt-in.
The threat model
Client-side storage is unencrypted at rest, readable by any same-origin script, and survives logouts. Persisting a name is fine; a CVV, password, SSN, or API token is a compliance liability.
Defaults are "nothing persists" — every persisted field must opt
in at its register() call site, and sensitive-named paths throw
at mount unless acknowledged.
Setup
Configure useForm with the operational settings (backend, key,
debounce window, etc.). Three input forms — pick the one that reads
best at the call site:
// Shorthand: built-in backend with library defaults
useForm({ schema, key: 'signup', persist: 'local' })
// Shorthand: custom FormStorage adapter with library defaults
useForm({ schema, key: 'signup', persist: encryptedStorage })
// Full options: needed when you want to override anything beyond the backend
useForm({
schema,
key: 'signup',
persist: { storage: 'local', debounceMs: 500 },
})
The shorthand forms are equivalent to { storage: 'local' } and
{ storage: encryptedStorage } — purely ergonomic sugar for the
common "I just want to pick a backend" case.
Then opt each field into persistence at its register() call site:
<input v-register="register('email', { persist: true })" />
<input v-register="register('phone', { persist: true })" />
<!-- This input does NOT persist — no opt-in. Value lives in memory only. -->
<input v-register="register('cvv')" />
Every typed character in an opted-in field debounces a write to the chosen backend. On next mount, those fields hydrate from the saved payload; non-opted fields fall back to schema defaults.
On a successful submit, the draft is cleared.
Reactive opt-in
The persist flag is reactive — flip a remember-me toggle to add or
remove the opt-in at runtime:
<script setup lang="ts">
const rememberMe = ref(false)
</script>
<template>
<input v-register="register('email', { persist: rememberMe })" />
<label><input type="checkbox" v-model="rememberMe" /> Remember me</label>
</template>
When rememberMe flips false → true, the directive's update hook
adds the opt-in. Future writes from THIS input persist. Flip it back
and the opt-in is removed; writes go in-memory only.
Sensitive-name protection
A small built-in heuristic flags sensitive-looking path names:
password, passwd, pwd, cvv, cvc, ssn, social-security, dob,
date-of-birth, pin, token, secret, api-key, private-key,
card-number, card, iban, routing-number, account-number, passport,
driver-license, mfa-secret, recovery-code
Opting one of these into persistence throws
SensitivePersistFieldError at mount:
<!-- Throws SensitivePersistFieldError -->
<input v-register="register('password', { persist: true })" />
If the persistence is intentional (custom encrypted adapter,
narrow-scope internal tool), pass acknowledgeSensitive: true:
<input v-register="register('password', { persist: true, acknowledgeSensitive: true })" />
The heuristic is a speed bump, not a soundness guarantee — adversarially named paths slip through. The real defence is the per-field opt-in.
Reset
form.reset() and form.resetField(path) wipe the persisted draft
alongside the in-memory clear. Opt-ins survive — the next keystroke
from a still-mounted opted-in input re-populates storage.
For "wipe storage but keep in-memory," use form.clearPersistedDraft().
Going further
- Persistence policy — sparse payloads, error inclusion, schema-change auto-invalidation, what persistence is NOT for.
- Persistence backends —
picking
'local'/'session'/'indexeddb', full options bag, switching backends safely, customFormStorageadapters, SSR considerations. - Persistence edge cases —
imperative
form.persist()andclearPersistedDraft(), the four component-binding patterns, cross-tab semantics, dev-mode warnings, gotchas.