useWizard
Compose a reactive wizard from an ordered list of step slots. Each slot holds a form, an affordance key for a screen with no data collection, or a function that picks one or the other at runtime. Attaform threads the same handle through navigation, status aggregation, URL sync, and a universal
handleSubmitthat validates the right scope on every call.
- Category
- Composable
- Signature
useWizard({ steps, ... })- Step slots
Form · string · function · lazy()- Aggregates
allValues · allErrors · forms · statuses
A linear three-step wizard. Each step keeps its own useForm call, its own schema, and its own reactive surface. The rail highlights wizard.currentStep, the progress bar reflects wizard.progress, and wizard.handleSubmit fires on the final step.
Each step is its own useForm with its own schema. useWizard orchestrates navigation, aggregates statuses, and exposes progress as the fraction of valid steps.
Forms and steps
Attaform separates two concepts that often get conflated in multistep code:
- A form is the artifact
useFormreturns: schema, fields, values, errors, submission. The central noun of Attaform. - A step is a position in the wizard's sequence. Steps come in two flavors:
- A collection step holds a form. The wizard gathers data here.
- An affordance step holds a bare string key, no form. The wizard uses it for screens that present rather than collect: a welcome card, a terms-and-conditions panel, a review surface, a confirmation card.
A wizard's steps list mixes the two freely. The shape of a typical onboarding flow:
import { useForm, useWizard } from 'attaform/zod'
import { z } from 'zod'
const shippingSchema = z.object({ address: z.string(), city: z.string() })
const contactSchema = z.object({ email: z.email(), phone: z.string() })
const paymentSchema = z.object({ cardNumber: z.string(), cvv: z.string() })
const billingSchema = z.object({ name: z.string(), address: z.string() })
const shipping = useForm({ schema: shippingSchema, key: 'shipping' })
const contact = useForm({ schema: contactSchema, key: 'contact' })
const payment = useForm({ schema: paymentSchema, key: 'payment' })
const billing = useForm({ schema: billingSchema, key: 'billing' })
const wizard = useWizard({
steps: ['welcome', shipping, contact, 'shipping-review', payment, billing, 'final-review'],
})
Seven positions, four collection steps, three affordance steps. The wizard treats every position uniformly. wizard.currentStep walks the list left to right. wizard.statuses[key] answers for every step, with affordance steps reading as always-valid so a rail dot or progress fraction doesn't need to special-case them. Under the hood, each affordance step gets a wizard-owned noop form backed by an empty AbstractSchema (no fields, validates as {}), so affordance positions participate in the same registry, status, and submission machinery as schema-backed forms without the wizard knowing about Zod (or any other adapter).
Affordance steps are a first-class building block, not an edge case. Onboarding flows live or die by the breathing room between dense collection screens: the welcome card that sets expectations, the review surface that lets the user check their work, the congratulations card that confirms a transaction landed. Each one is one string in the steps array.
Step slots
Each entry in steps is a slot. Four slot kinds compose the list:
import { useForm, useWizard, lazy } from 'attaform/zod'
const wizard = useWizard({
steps: [
shipping, // form slot
'shipping-review', // affordance slot
(ctx) => (ctx.forms.contact.values.kind === 'business' ? billing : payment), // function slot
lazy((ctx) => buildSummaryForm(ctx)), // memoized lazy slot
],
})
- Form slot: a form built with
useForm. The wizard surfaces it as-is. - Affordance slot: a bare string. Becomes the step's key; the wizard generates a noop form under it.
- Function slot:
(ctx) => Form | string | undefined. Re-evaluates reactively asctx.forms.<key>.valuesmutate. The picked result replaces the slot's compiled step. Returningundefineddrops the slot. - Lazy slot:
lazy((ctx) => ...). Memoized by the resolver's tracked reactive reads. Re-fires on dep change orwizard.reset(). Right shape for resolvers expensive enough that thrash matters.
See Step slots for the full slot reference, including the ctx shape and the rules around reactive re-evaluation.
Options
useWizard takes one options bag. steps is required; the rest default sensibly for the common URL-synchronized wizard case.
| Option | Type | Default | What it does |
|---|---|---|---|
steps | Array<StepSlot> | required | Ordered list of slots that compile into the wizard's step list. See Step slots. |
key | string | synthetic | Identifier registered in the per-app registry for injectWizard. Anonymous wizards get a synthetic SSR-stable key under the hood. |
defaultStatuses | Record<key, FormStatus> | sync factory | async factory | unset | Seed payload used while a form's defaults are still resolving. See Statuses. |
progress | (steps) => number | valid-step fraction | Override the default progress computation. The override is invoked inside a computed, so read reactive sources only. |
focusFirstError | boolean | true | On final-step submission failure, jump to the first failing form and run its applyInvalidSubmitPolicy() (focus / scroll per the form's onInvalidSubmit configuration). |
restore | () => { step? } | false | URL ?step=<key> | Source of truth for the active step. Watched reactively; re-applies on URL changes. See URL sync. |
persist | ({ step }) => void | false | URL ?step=<key> | Destination for the active step. Invoked when currentStep changes (diffed to break the restore-persist loop). See URL sync. |
The wizard handle
useWizard returns a reactive handle. Every reactive read is a plain getter, no .value. Use the rail of links below to reach the page that covers each surface in depth.
| Member | What it is |
|---|---|
key | The wizard's identifier (explicit or synthetic). |
currentStep | The active step's key. Typed string when the slot tuple is statically non-empty; string | undefined when a function slot can drop. |
activeForm | The active step's form handle, identity-equal to wizard.forms[currentStep]. Narrows in lockstep with currentStep. |
activeIndex | Zero-based position of the active step. |
isFinalStep | true when activeIndex === count - 1. Gates the Next-vs-Finish split in templates. |
count | steps.length. Includes affordance positions. |
steps | Ordered list of compiled { key, form } slots. See Step slots. |
forms | Record indexable by step key. See Aggregates. |
canAdvance | true when a next step exists. Pure positional check. |
canGoBack | true when a prior step exists. |
complete | Forward-looking: isFinalStep && every-form-valid. See Statuses. |
done | Monotonic: true once handleSubmit lands on the final step; only reset() flips it back. See Statuses. |
submitting | true while a wizard.handleSubmit call is in flight. Global re-entrance guard. |
submissionAttempts | Count of wizard.handleSubmit invocations. |
visited | Append-only breadcrumb of navigated step keys. |
progress | Fraction in [0, 1]. Defaults to valid-step ratio. |
statuses | Per-key FormStatus proxy. See Statuses. |
allValues | Namespaced record of each step's values. See Aggregates. |
allErrors | Namespaced record of each step's validation errors. See Aggregates. |
next / back | Positional navigation. Refuses while submitting. |
goTo | Jump to a specific step by key. Dev-warn on unknown keys. |
handleSubmit | Universal submission handler. See handleSubmit. |
reset | Zeros wizard lifecycle, resets every form, returns to steps[0], clears the persisted step, flips done back. |
Submission, in one place
wizard.handleSubmit(onSubmit, onError?) returns one event handler that fits every step. Intermediate calls validate the active form and advance; the final call validates every form and stays put. The same handler binds to every Next-and-Finish button.
const onSubmit = wizard.handleSubmit(async (ctx) => {
if (!ctx.isFinal) return
await api.checkout({
shipping: ctx.get(shipping),
payment: ctx.get(payment),
})
})
See handleSubmit for the universal handler depth, including focusFirstError, re-entrance, error aggregation, and the ctx shape.
Navigation
await wizard.next() // advance one position
wizard.back() // retreat one position
wizard.goTo('shipping-review') // jump to a specific step by key
next / back / goTo are pure positional navigation. None of them validate; that's handleSubmit's job. Out-of-bounds calls dev-warn and no-op; mid-submission navigation is blocked until wizard.submitting clears. A wizard wired into a checkout or signup never throws on navigation.
URL sync, on by default
A wizard with no extra options reads its starting step from ?step=<key> on the URL and writes the active step back as the user navigates. Reloads land on the same step, the browser back / forward buttons walk the flow, and the URL is shareable. On the server, the wizard reads the incoming request's ?step so deep-links render the right step on the first byte.
import { useForm, useWizard } from 'attaform/zod'
const wizard = useWizard({
steps: ['welcome', shipping, payment, 'final-review'],
// restore: defaults to reading ?step=<key>
// persist: defaults to writing ?step=<key>
})
To rename the param, scope it across multiple wizards on the same page, or wire the state to non-URL storage (localStorage, a broadcast channel, a router state field), pass restore and persist callbacks. See URL sync.
Cross-component access with key
Pass key to give the wizard a stable identifier. Any descendant component reaches the same reactive handle through injectWizard without prop-threading:
const wizard = useWizard({
steps: ['welcome', shipping, payment, 'final-review'],
key: 'checkout',
})
Anonymous wizards (option omitted) get a synthetic SSR-stable key under the hood and remain reachable via ambient injectWizard() from descendants of the parent that called useWizard.
Degenerate inputs
Conditions that would otherwise crash the surrounding app dev-warn and degrade:
- Empty
stepsarray. Dev-warns and degrades.wizard.currentStepreads asundefined, navigation refuses, the surrounding app keeps rendering. - Duplicate step keys. First occurrence wins; later duplicates dev-warn and drop.
- Same form ref in multiple slots. First slot keeps the canonical position; later slots dev-warn and drop.
defaultStatuseswith an unknown key. Ignored; known entries still apply.restorereturns a key not in the compiled steps. Dev-warn; the wizard falls back to the first step.
A wizard wired into a signup or checkout never crashes the surrounding app for shapes that are clearly a mistake.
Where to next
- Step slots for the full slot reference (form, string, function, lazy).
injectWizardfor cross-component access to the wizard handle.- Statuses for the per-step rollup,
defaultStatuses,complete, anddone. - Aggregates for
allValues,allErrors, andforms. - handleSubmit for the universal submission pipeline.
- URL sync for
restore/persistand the SSR hand-off. - Patterns for branching, review surfaces, per-step persistence and undo.