Resumable wizards
A multistep flow a user can leave and return to needs three things restored: the active step, each form's values, and which gates the session has cleared. Attaform already round-trips the step through the URL, values ride in through each form's
defaultValues, and gate clearance rides in throughuseWizard'sdefaultStatuses. All three load from one fetch, and none of them needs a table of its own.
- Category
- Restore / persist
- Restores
step · values · gates- Storage
- one row, no extra tables
- Authority
- the server, re-checked per write
The three things a wizard restores
A resumable wizard is three pieces of state, each with its own channel:
- The active step, handled for you by URL sync.
?step=<key>is read at construction and written on navigation, so a reload lands where the user left off. - Each form's values, restored through that form's
defaultValues. Validity, dirty, and error counts are recomputed from the restored values, so you never store them. - Gate clearance, restored through
defaultStatuses. A gate's cleared state is the one fact you cannot recompute from values, because "the box validates" is not "the prerequisite was confirmed" (that split is the whole reasongateexists). So it is the one status worth persisting.
One fetch, one row
Load the session once and feed the three channels from the same payload. The gate flags are a small JSON column on the row you already keep for the form values, so a resumable wizard adds no tables:
import { useForm, useWizard, gate } from 'attaform'
import { z } from 'zod'
const consentSchema = z.object({ accepted: z.literal(true) })
const shippingSchema = z.object({ address: z.string(), city: z.string() })
// What you persist per session: each form's values, plus a `gates` column.
type SavedSession = {
consent: { accepted: true }
shipping: { address: string; city: string }
gates: Record<string, { gate: 'cleared' | 'uncleared' }>
}
// One call returns the whole row.
const saved: SavedSession = await loadSession(sessionId)
const consent = useForm({ schema: consentSchema, key: 'consent', defaultValues: saved.consent })
const shipping = useForm({ schema: shippingSchema, key: 'shipping', defaultValues: saved.shipping })
const wizard = useWizard({
steps: [gate(consent), shipping],
defaultStatuses: saved.gates, // e.g. { consent: { gate: 'cleared' } }
})
Values in through defaultValues, gate in through defaultStatuses, both from saved. The gates column holds one entry per gate, so ten gates cost the same zero extra tables as one.
Writing it back
A gate clears on its member form's clean submit, so the write-back rides on the submit you already have. Record the flag on the server there. The client's gate latch has already moved, so there is nothing else to sync:
consent.handleSubmit(async (data) => {
await confirmConsentOnServer(sessionId, data) // server records gates.consent = 'cleared'
})
To re-seal a gate later, a revoked consent or a rolled-back approval, wizard.relock(key, commit) runs your server write and re-seals only if it resolves clean, keeping the row and the client in step.
The server stays the authority
The seed is a first-paint optimization, not an access check. Because defaultStatuses seeds the gate at construction, a client that tampers with its saved gates value renders the flow open. That is fine, as long as the guarantee lives where gate() puts it: in the data, enforced by the server. Re-check the prerequisite on every downstream write, so a spoofed client seed shows an open rail but the server still refuses the mutation. The gate keeps the client honest for free; the server keeps it honest for real.
This is why gate clearance is not a field in the form's schema. Keeping it out of values means it is never submitted as if it were user data, never validated as if the client owned it, and never mistaken for the authority. It is server-owned state that the client is merely allowed to cache.
Where to next
- URL sync for the active-step half of the round-trip.
gatefor the prerequisite model andrelock.- Statuses for the full
defaultStatusesseed shape. - Defaults from the schema for how
defaultValuesrehydrates each form.