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 handleSubmit that 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.

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/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 as ctx.forms.<key>.values mutate. The picked result replaces the slot's compiled step. Returning undefined 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.

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, FormStatus> | sync factory | async factoryunsetSeed payload used while a form's defaults are still resolving. See Statuses.
progress(steps) => numbervalid-step fractionOverride the default progress computation. The override is invoked inside a computed, so read reactive sources only.
focusFirstErrorbooleantrueOn final-step 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.
activeFormThe active step's form handle, identity-equal to wizard.forms[currentStep]. 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 handleSubmit lands on the final step; only reset() flips it back. See Statuses.
submittingtrue while a wizard.handleSubmit call is in flight. Global re-entrance guard.
submissionAttemptsCount of wizard.handleSubmit invocations.
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.
handleSubmitUniversal submission handler. See handleSubmit.
resetZeros 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.

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 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; 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).
  • 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.