Compose multiple formbridge schemas into a step-by-step flow.
- Each step owns its own schema
- Values are accumulated across steps automatically
- The hook gives you navigation, progress, skip, and final submission helpers without introducing a separate mental model
- Pass
stepId+onStepChangeto let a router or native navigator own cross-page / cross-screen navigation while the wizard keeps the state machine
Step shape
Each entry in the steps array is a WizardStep with the following shape:
| Method | Type | Description |
|---|---|---|
id | string | Stable identifier - used for URL routing, persistence keys, goToStep(), and WizardStepChangeEvent.step.id. Keep it URL-safe |
label | string | Human-readable title rendered in step indicators, breadcrumbs, and accessible names. Safe to translate |
schema | FormSchema | FormBridge schema owned by this step. Use {} satisfies FormSchema for pure review/confirmation screens |
optional? | boolean | Marks the step as skippable. Required to enable wizard.skip(). Does not exclude the step from validation when the user stays on it |
condition? | (allValues) => boolean | Dynamic visibility rule evaluated against wizard.allValues on every render. When false, the step is removed from visibleSteps |
formOptions? | Partial<UseFormBridgeOptions<S, TPlatform>> | Per-step overrides forwarded to the underlying useFormBridge() - validateOn, revalidateOn, validatorBridge, analytics, globalDefaults, persist, initialValues (merged with accumulated wizard values) |
Example
WizardSteps.tsts
| 1 | const steps: WizardStep[] = [ |
| 2 | { id: 'account', label: 'Account', schema: accountSchema }, |
| 3 | { |
| 4 | id: 'company', |
| 5 | label: 'Company details', |
| 6 | schema: companySchema, |
| 7 | optional: true, |
| 8 | condition: (v) => v.accountType === 'business', |
| 9 | formOptions: { validateOn: 'onBlur' }, |
| 10 | }, |
| 11 | { id: 'review', label: 'Review', schema: {} satisfies FormSchema }, |
| 12 | ] |
Options
Complete useFormBridgeWizard() options surface:
| Method | Type | Description |
|---|---|---|
onSubmit | (allValues) => void | Promise<void> | Required - final submit handler called once after the last step passes validation. Receives merged allValues. Throwing routes to onSubmitError; resolving flips isSubmitSuccess and clears every persisted draft |
onSubmitError? | (error) => string | Maps a thrown error to the submitError string. Defaults to "An error occurred. Please try again." |
persist? | Omit<PersistOptions, "key"> & { key: string } | Enables auto-saving. Persists both the wizard snapshot (current step + completed set + merged values) and per-step drafts under <key>:<stepId>. Supports storage, ttl, exclude, debounce, onRestore, onSaveError, version |
validateOn? | 'onBlur' | 'onChange' | 'onTouched' | 'onSubmit' | Default validation trigger for every step. Default 'onTouched'. Overridden per step via formOptions.validateOn |
revalidateOn? | 'onBlur' | 'onChange' | 'onTouched' | 'onSubmit' | Default re-validation trigger. Default 'onChange'. Overridden per step via formOptions.revalidateOn |
stepId? | string | Controlled active step id. When provided, the wizard reads the active step from this prop and never self-advances. Pair with onStepChange to drive navigation from a router |
initialStepId? | string | Uncontrolled starting step id. Ignored when stepId is controlled or when a persisted snapshot restores a step. Defaults to steps[0].id |
onStepChange? | (event: WizardStepChangeEvent) => void | Fires every time the active step changes. Navigation bridge + analytics/telemetry hook |
Uncontrolled (default): single-page wizard
SignupWizard.tsxtsx
| 1 | const wizard = useFormBridgeWizard(steps, { |
| 2 | persist: { key: 'signup-wizard', storage: 'local' }, |
| 3 | onSubmit: async (allValues) => { |
| 4 | await api.signup(allValues) |
| 5 | }, |
| 6 | onSubmitError: (err) => (err instanceof ApiError ? err.message : 'Network error.'), |
| 7 | }) |
Controlled: route-per-step on the web
SignupRoute.tsxtsx
| 1 | const { stepId } = useParams() |
| 2 | const navigate = useNavigate() |
| 3 | |
| 4 | const wizard = useFormBridgeWizard(steps, { |
| 5 | stepId, |
| 6 | initialStepId: 'account', |
| 7 | persist: { key: 'signup-wizard' }, |
| 8 | onStepChange: ({ step }) => navigate('/signup/' + step.id), |
| 9 | onSubmit: api.signup, |
| 10 | }) |
WizardStepChangeEvent payload passed to onStepChange:
| Method | Type | Description |
|---|---|---|
step | WizardStep | The step the wizard is navigating to (always a member of visibleSteps) |
index | number | 0-based index of step inside visibleSteps. Use for "Step N of M" labels |
previousStep | WizardStep | null | The step the user is leaving. null only for initial "restore" / "fallback" events |
previousIndex | number | Index of previousStep, or -1 when previousStep is null |
reason | 'next' | 'prev' | 'goTo' | 'goToStep' | 'skip' | 'restore' | 'fallback' | What triggered the transition (see reason table below) |
`reason` values
| Value | Source |
|---|---|
'next' / 'prev' | wizard.next() or wizard.prev() (standard Back / Continue flow) |
'goTo' / 'goToStep' | wizard.goTo(index) or wizard.goToStep(id) (step indicator click, resume flow) |
'skip' | wizard.skip() on an optional step |
'restore' | Post-hydration resume to the step saved in persistent storage |
'fallback' | Controlled stepId did not match any visible step - wizard fell back to the first visible one (tell your router to replace the URL) |
Example
onStepChange.tsts
| 1 | onStepChange: ({ step, previousStep, reason }) => { |
| 2 | if (reason === 'fallback') { |
| 3 | navigate('/signup/' + step.id, { replace: true }) |
| 4 | return |
| 5 | } |
| 6 | |
| 7 | if (reason === 'next' || reason === 'prev') { |
| 8 | analytics.track('wizard_step_change', { |
| 9 | from: previousStep?.id, |
| 10 | to: step.id, |
| 11 | }) |
| 12 | } |
| 13 | |
| 14 | navigate('/signup/' + step.id) |
| 15 | }, |
Return
Complete useFormBridgeWizard() return surface:
Active step
| Method | Type | Description |
|---|---|---|
currentStep | UseFormBridgeReturn<FormSchema, TPlatform> | Full useFormBridge() return value for the active step. Destructure { Form, fields, state, getValues, validate, … }. During hydration or empty visibility, points to an empty-schema placeholder so hooks stay stable |
step | WizardStep | null | Active step descriptor, or null during isHydrating / empty visibleSteps. Always guard if (!wizard.step) return null |
currentStepId | string | null | Shorthand for step?.id. Safe to use as React key |
currentStepIndex | number | 0-based index inside visibleSteps, or -1 when step is null |
Step catalog
| Method | Type | Description |
|---|---|---|
totalSteps | number | visibleSteps.length. Use for "Step {currentStepIndex + 1} / {totalSteps}" |
allSteps | WizardStep[] | Raw array you passed in, including steps hidden by condition |
visibleSteps | WizardStep[] | allSteps filtered through each step's condition. What every index-based API operates on |
isFirstStep | boolean | No visible step before the active one. Disable your "Back" button |
isLastStep | boolean | No visible step after the active one. Flip your primary button label to "Finish / Submit" |
Progress
| Method | Type | Description |
|---|---|---|
progress | number | Rounded percentage Math.round(completedSteps.size / visibleSteps.length * 100) |
completedSteps | Set<string> | Ids of steps whose validation has passed at least once. Not affected by prev() / goTo() |
allValues | Record<string, unknown> | Merged values across the whole wizard: { ...accumulatedValues, ...currentStep.state.values }. Always reflects the live values the user just typed |
Navigation
| Method | Type | Description |
|---|---|---|
next | () => Promise<boolean> | Validates the active step, saves its draft, merges values, marks complete, advances. Resolves false on validation failure. Does not call `onSubmit` on the last step - use submit() |
prev | () => void | Synchronously moves back one visible step. Never validates. No-op on the first step |
goTo | (index, skipValidation?) => Promise<boolean> | Jumps to visibleSteps[index]. Forward jumps validate the current step unless skipValidation is true; backward jumps always skip |
goToStep | (stepId, skipValidation?) => Promise<boolean> | Same semantics as goTo, addressed by step id. Prefer this when driven by a router |
skip | () => boolean | Advances one step only when the current step has optional: true and is not the last. Does not validate |
Final submission
| Method | Type | Description |
|---|---|---|
submit | () => Promise<void> | Validates, merges, calls options.onSubmit(allValues). On success flips isSubmitSuccess, clears every persisted draft. Errors route through onSubmitError |
isSubmitting | boolean | true between entering submit() and resolution. Drive button spinners |
isSubmitSuccess | boolean | true once onSubmit has resolved. Stays true until unmount - use to render the success screen |
submitError | string | null | Last error message from onSubmitError (or default fallback). Cleared on next submit() |
Hydration
| Method | Type | Description |
|---|---|---|
isHydrating | boolean | First render while reading the saved snapshot from storage. During hydration step is null. Gate your render with if (wizard.isHydrating) return <Spinner /> |
Example - rendering a wizard body
WizardBody.tsxtsx
| 1 | if (wizard.isHydrating || !wizard.step) return <Spinner /> |
| 2 | |
| 3 | const { Form, fields } = wizard.currentStep |
| 4 | |
| 5 | return ( |
| 6 | <> |
| 7 | <progress value={wizard.progress} max={100} /> |
| 8 | <p>Step {wizard.currentStepIndex + 1} / {wizard.totalSteps}</p> |
| 9 | |
| 10 | <Form onSubmit={wizard.isLastStep ? wizard.submit : wizard.next}> |
| 11 | {'email' in fields && <fields.email />} |
| 12 | {'password' in fields && <fields.password />} |
| 13 | {'firstName' in fields && <fields.firstName />} |
| 14 | |
| 15 | <div> |
| 16 | {!wizard.isFirstStep && ( |
| 17 | <button type='button' onClick={wizard.prev}>Back</button> |
| 18 | )} |
| 19 | {wizard.step.optional && !wizard.isLastStep && ( |
| 20 | <button type='button' onClick={wizard.skip}>Skip</button> |
| 21 | )} |
| 22 | <Form.Submit> |
| 23 | {wizard.isSubmitting ? 'Saving…' : wizard.isLastStep ? 'Finish' : 'Next'} |
| 24 | </Form.Submit> |
| 25 | </div> |
| 26 | </Form> |
| 27 | |
| 28 | {wizard.submitError && <p role="alert">{wizard.submitError}</p>} |
| 29 | </> |
| 30 | ) |
Why it matters
Use the wizard hook when one large form would feel heavy or fragile. It keeps the same schema-first API while making multi-step onboarding, checkout, or settings flows much easier to maintain.
- Same-page usage still works great for classic steppers
- Controlled mode lets the same hook power route-based web flows and screen-based native flows without rewriting validation or persistence