Per-field opt-in policy
Two gates, one rule: a path persists only when the form opens a backend AND the field's register call says yes. Neither alone is enough.
Toggle the per-field checkboxes, type into the inputs, then refresh the page. Only the opted-in fields rehydrate; the others land empty even though the form is persisting to sessionStorage and the schema has defaults. That's the two-gate policy in action: adding a new field can't accidentally leak into client-side storage unless its register call site explicitly says so.
The two gates
useForm({
schema,
persist: 'session', // ← gate 1: form-level backend
})
<input v-register="form.register('email', { persist: true })" />
<!-- ← gate 2: per-field -->
Without both, no writes hit the backend. The form-level opt-in says which backend to use; the field-level opt-in says which paths go into the sparse payload. The asymmetry is intentional: opt-in beats opt-out for forms that grow new fields over time.
The sparse payload
The persisted envelope contains only opted-in paths:
// Schema: { email: string, phone: string, cvv: string }
// register('email', { persist: true })
// register('phone', { persist: true })
// register('cvv') ← no opt-in
// Persisted payload, written under key attaform:signup:${fingerprint}
{
v: 4, // attaform-internal envelope version
data: {
form: { email: '…', phone: '…' } // no `cvv`
}
}
The v field is internal to Attaform; it tracks the on-disk format and bumps only when Attaform changes the serialized shape. Drafts saved against a stale envelope version drop on read with a one-time dev warning.
On hydration, opted-in fields restore from storage; non-opted-in fields come from schema defaults. The opt-in set can change between mounts: a previously-persisted path that's no longer opted in stays in storage until the next write (which won't include it) or an explicit form.clearPersistedDraft(path).
Reactive opt-in
The persist flag is reactive. Pass a ref<boolean> and the directive's update hook adds or removes the opt-in at runtime:
<script setup lang="ts">
const rememberMe = ref(false)
</script>
<template>
<input v-register="form.register('email', { persist: rememberMe })" />
<label><input type="checkbox" v-model="rememberMe" /> Remember me</label>
</template>
Flip rememberMe false → true and the directive adds the opt-in. Future writes from this input persist. Flip it back and the opt-in is removed; writes go in-memory only. No remount, no manual cleanup.
Cross-SFC behavior
Two SFCs binding the same path under the same form key share the FormStore and the persistence registry. Opt-ins are per-DOM-element, not per-SFC:
- SFC A renders an input bound to
'email'withpersist: true→ A's element opted in. - SFC B renders an input bound to
'email'withoutpersist→ B's element NOT opted in. - Typing in A persists. Typing in B doesn't.
Unmount SFC A and B's typing stops persisting (no opt-ins remain). Re-mount A and the new element gets a fresh opt-in. The registry tracks elements via WeakMap; rapid mount/unmount cycles auto-clean without any explicit dispose.
Including errors
Default include: 'form' persists just the values. Server-side validation errors on reload would be stale by then.
For multi-step wizards where reconstructing errors is expensive, include: 'form+errors' persists and re-hydrates the errors map alongside the values:
useForm({
schema,
persist: { storage: 'local', include: 'form+errors' },
})
Errors on non-opted-in paths are dropped from the envelope; a persisted error without a persisted value would dangle on rehydration.
Dev-mode footgun checks
Two symmetric warnings catch "wired half the pipeline" bugs (once per form in dev, silent in production):
persist:configured but no field opts in → drafts never save.register({ persist: true })but nopersist:on the form → opt-ins recorded, no writes land.
The warnings name the form key and (where applicable) the offending path.
Where to next
- Storage backends: the first of the two gates.
- Sensitive-name protection: the heuristic that warns and skips the opt-in on
password/cvv/ssnpaths. - Imperative persistence:
form.persist()andform.clearPersistedDraft()for "Save draft" buttons and explicit cleanup.