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>
)
}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.