Form Wizard
A multi-step form built on TanStack Form’s composition model. Values are nested per step, each step is a withForm component that validates its own slice through a FormGroup, and one form instance holds everything.
What it is built on
- One form instance.
useFormWizardcreates a singleuseAppFormfrom yourformOptions, wiresrevalidateLogic()and a full-formonDynamicschema for the final submit, and owns step navigation, conditional visibility, and optional persistence. - Nested values per step.
defaultValuesis grouped by step ({ account: {...}, plan: {...} }), so each step owns a slice of the form. - Per-step schemas via
FormGroup. Each step is awithFormcomponent whoseform.FormGroupvalidates only that step’s slice with its own schema on advance. The fullschemapassed touseFormWizardis not sliced per step; it is only the final-submit backstop. - A composable Stepper renders the progress indicator from the visible steps.
Installation
npx shadcn@latest add @uipath/form-wizardHow it works
useFormWizardreturns theform, the visiblesteps, thecurrentStepId, and navigation helpers (next,back,goToStep,reset).- Each step is authored with
withFormand wraps aform.FormGroup. ItsonGroupSubmit(fired only when the group’s schema passes) callsnext(); on the last stepnext()runs the fullform.handleSubmit(). FormWizardprovides the wizard through context.FormWizardSteprenders its child only when it is the active step.FormWizardStepsrenders the Stepper, andFormWizardNavrenders Back plus a submit button that advances the current group.
The demo persists to localStorage, so refreshing the page restores the answers and the current step. Choosing the Enterprise plan reveals a conditional Billing step.
Usage
Define nested formOptions and a schema per step, then author each step with withForm:
import { formOptions } from '@tanstack/react-form'
import { z } from 'zod'
import { FieldGroup } from '@/components/ui/field'
import { withForm } from '@/components/ui/form'
import {
FormWizard,
FormWizardNav,
FormWizardStep,
FormWizardSteps,
useFormWizard,
useFormWizardContext,
type WizardStepDef,
} from '@/components/ui/form-wizard'
const accountSchema = z.object({ email: z.email('Enter a valid email.') })
const planSchema = z.object({ tier: z.string().min(1) })
const wizardSchema = z.object({ account: accountSchema, plan: planSchema })
type Values = z.infer<typeof wizardSchema>
const wizardOpts = formOptions({
defaultValues: { account: { email: '' }, plan: { tier: 'free' } },
})
const steps: WizardStepDef<Values>[] = [
{ id: 'account', title: 'Account' },
{ id: 'plan', title: 'Plan', condition: (v) => v.account.email !== '' },
]
const AccountStep = withForm({
...wizardOpts,
render: function Render({ form }) {
const { next } = useFormWizardContext()
return (
<form.FormGroup
name="account"
validators={{ onDynamic: accountSchema }}
onGroupSubmit={() => next()}
>
{(group) => (
<form
onSubmit={(e) => {
e.preventDefault()
void group.handleSubmit()
}}
>
<FieldGroup>
<form.AppField name="account.email">
{(field) => <field.TextField type="email" label="Email" />}
</form.AppField>
<FormWizardNav />
</FieldGroup>
</form>
)}
</form.FormGroup>
)
},
})
function SignUpWizard() {
const wizard = useFormWizard<Values>({
formOptions: wizardOpts,
schema: wizardSchema,
steps,
persist: { key: 'sign-up' },
onSubmit: (values) => console.log(values),
})
return (
<FormWizard wizard={wizard}>
<FormWizardSteps />
<FormWizardStep stepId="account">
<AccountStep form={wizard.form} />
</FormWizardStep>
{/* ...remaining steps... */}
</FormWizard>
)
}Bring your own UI
useFormWizard is the headless engine: it renders nothing and returns the form, the visible steps, navigation helpers, and derived flags (stepIndex, isFirst, isLast, progress). The styled FormWizardSteps and FormWizardNav are one recipe on top of it. Consumers get three levels of control.
- Defaults. Drop in
<FormWizardSteps />and<FormWizardNav />for the Apollo look. - Restyle with render props. Pass a function child to keep the wiring but own the markup.
FormWizardStepscalls it with{ steps, stepIndex, goToStep }, andFormWizardNavwith{ back, next, isFirst, isLast, form }. - Fully headless. Ignore the styled parts and drive everything from the
useFormWizardreturn value directly.
FormWizard and FormWizardStep also accept asChild to swap their wrapper element.
The demo below replaces the Stepper with a custom progress rail and the nav with custom buttons, using the render-prop escape hatches:
// Keep the wiring, own the design.
<FormWizardSteps>
{({ steps, stepIndex, goToStep }) => (
<MyStepRail steps={steps} active={stepIndex} onJump={goToStep} />
)}
</FormWizardSteps>
<FormWizardNav>
{({ back, next, isFirst, isLast }) => (
<MyToolbar onBack={back} onNext={next} atStart={isFirst} atEnd={isLast} />
)}
</FormWizardNav>The default nav’s “Next” button is a submit that runs the current step’s FormGroup validation before onGroupSubmit calls next(). A custom nav that calls next() directly skips that per-step validation, so drive advancement through a submit button (as the demo does) when you want the same guardrails.
Step definition
| Field | Type | Notes |
|---|---|---|
id | string | Stable identifier used for navigation and persistence. |
title | ReactNode | Shown in the Stepper. |
description | ReactNode | Optional secondary line in the Stepper. |
condition | (values) => boolean | Optional. When it returns false the step is hidden from navigation and the Stepper. |
optional | boolean | Reserved for steps that should not block advancing. |
Each step’s fields and validation live in its withForm component, not in the step definition.
Conditional steps
A step with a condition is only shown when the predicate passes against the current (nested) values. Hidden steps are skipped by next/back and dropped from the Stepper.
Persistence
Pass persist: { key } to save the values and current step to localStorage, backed by useLocalStorage from @mantine/hooks. Every change is written immediately so a reload resumes where you left off, the saved state seeds defaultValues on load, and a successful submit or wizard.reset() clears the entry.
Validation
Advancing runs the current step’s FormGroup schema against its slice; errors render inline on that step’s fields. The final next() calls form.handleSubmit(), which validates the full onDynamic schema as a backstop.