.md

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 handleSubmit that validates the whole wizard 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, each Next calls wizard.tryNext() to gate advance on the active step, and Finish runs wizard.handleSubmit over the whole wizard.

Step 1 of 3

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 useForm returns: 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'
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'

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 | null | undefined. Re-evaluates reactively as ctx.forms.<key>.values mutate. The picked result replaces the slot's compiled step. Returning either nullish value drops the slot.
  • Lazy slot: lazy((ctx) => ...). Memoized by the resolver's tracked reactive reads. Re-fires on dep change or wizard.reset(). Right shape for resolvers expensive enough that thrash matters.

gate() joins them as a wrapper rather than a fifth kind: it takes any of the four, marks that position a hard prerequisite, and leaves how the slot resolves alone. A function slot can return one, so whether a position is a gate at all can be a runtime decision.

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.

OptionTypeDefaultWhat it does
stepsArray<StepSlot>requiredOrdered list of slots that compile into the wizard's step list. See Step slots.
keystringsyntheticIdentifier registered in the per-app registry for injectWizard. Anonymous wizards get a synthetic SSR-stable key under the hood.
defaultStatusesRecord<key, FormStatusSeed> | sync factory | async factoryunsetSeed payload used while a form's defaults are still resolving, and the only way to start a gate() cleared. See Statuses.
progress(steps) => numbervalid-step fractionOverride the default progress computation. The override is invoked inside a computed, so read reactive sources only.
focusFirstErrorbooleantrueOn submission failure, jump to the first failing form and run its applyInvalidSubmitPolicy() (focus / scroll per the form's onInvalidSubmit configuration).
restore() => { step? } | falseURL ?step=<key>Source of truth for the active step. Watched reactively; re-applies on URL changes. See URL sync.
persist({ step }) => void | falseURL ?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.

MemberWhat it is
keyThe wizard's identifier (explicit or synthetic).
currentStepThe active step's key. Typed string when the slot tuple is statically non-empty; string | undefined when a function slot can drop.
activeFormA live view of the active step's form; operating through it always targets the current step. It is a view, not the handle itself, so reach for wizard.forms[currentStep] when you need the raw form. Narrows in lockstep with currentStep.
activeIndexZero-based position of the active step.
isFinalSteptrue when activeIndex === count - 1. Gates the Next-vs-Finish split in templates.
countsteps.length. Includes affordance positions.
stepsOrdered list of compiled { key, form } slots. See Step slots.
formsRecord indexable by step key. See Aggregates.
canAdvancetrue when a next step exists. Pure positional check.
canGoBacktrue when a prior step exists.
completeForward-looking: isFinalStep && every-form-valid. See Statuses.
doneMonotonic: true once a handleSubmit succeeds; only reset() flips it back. See Statuses.
submittingtrue while a wizard.handleSubmit call is in flight. Global re-entrance guard.
submitErrorThe Error a submit callback threw, parked rather than re-thrown. null otherwise. See handleSubmit.
submissionAttemptsCount of wizard.handleSubmit runs. Bumped before the callbacks, so a read inside onSubmit / onError already counts the run in progress. See Aggregates.
visitedAppend-only breadcrumb of navigated step keys.
progressFraction in [0, 1]. Defaults to valid-step ratio.
statusesPer-key FormStatus proxy. See Statuses.
allValuesNamespaced record of each step's values. See Aggregates.
allErrorsNamespaced record of each step's validation errors. See Aggregates.
next / backPositional navigation. Refuses while submitting.
goToJump to a specific step by key. Dev-warn on unknown keys.
tryNextValidate the active step, advance iff valid; resolves to whether the pin moved. The inline-bindable gated Next. See handleSubmit.
handleSubmitWhole-wizard submission handler; never advances. See handleSubmit.
resetZeros wizard lifecycle, resets every form, returns to steps[0] and persists that key, flips done back, re-seals every gate, and re-applies the defaultStatuses gate seed.
relockRe-seal a cleared gate, contingent on a required commit callback: awaits your server-side revoke, re-seals only if it resolves clean, resolves whether it sealed. Never opens a gate. See gate.

Submission, in one place

wizard.handleSubmit(onSubmit, onError?) returns one event handler that validates the entire step list, from any step, and calls onSubmit once with every step's values. It never advances; bind it to your Finish button.

const onFinish = wizard.handleSubmit(async (ctx) => {
  await api.checkout({
    shipping: ctx.get(shipping),
    payment: ctx.get(payment),
  })
})

To gate a Next button on the active step's validity, reach for wizard.tryNext():

await wizard.tryNext() // validate the active step, advance only on a clean pass

wizard.tryNext() binds straight to a control (@click="wizard.tryNext()") and resolves to whether the pin moved. For custom valid or invalid handling, compose the active form's submit with next() instead: wizard.activeForm.handleSubmit(() => wizard.next()).

See handleSubmit for the whole-wizard handler in depth, including the gated-advance composition, focusFirstError, re-entrance, error aggregation, and the ctx shape.

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 positional navigation, and they do not validate. To gate an advance on the active step, use wizard.tryNext() (or compose wizard.activeForm.handleSubmit(() => wizard.next()) when you need custom valid or invalid handling). 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.

One carve-out, and it is the whole point of gate(): next() on an uncleared gate step behaves like tryNext(), submitting the step and moving only on a clean pass. So wiring a Next button straight to next() can never step around a prerequisite, and goTo refuses a target sealed behind one. Once the gate clears, next() is plain navigation again.

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'

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 steps array. Dev-warns and degrades. wizard.currentStep reads as undefined, 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.
  • defaultStatuses with an unknown key. Ignored; known entries still apply.
  • restore returns a key not in the compiled steps. Dev-warn, and what happens next depends on when it arrives. On the first read, at construction, the wizard falls back to the first step. On any later read, when restore re-fires because the URL changed, the unknown key is ignored and the pin stays where it is, so a hand-edited ?step= never yanks a user out of the step they were on.

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).
  • injectWizard for cross-component access to the wizard handle.
  • Statuses for the per-step rollup, defaultStatuses, complete, and done.
  • Aggregates for allValues, allErrors, and forms.
  • handleSubmit for the universal submission pipeline.
  • URL sync for restore / persist and the SSR hand-off.
  • Patterns for branching, review surfaces, per-step persistence and undo.