react-formbridge
Browse documentation
Hooksv1.0.2

useFormBridgeWizard()

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 + onStepChange to let a router or native navigator own cross-page / cross-screen navigation while the wizard keeps the state machine
import { useState } from 'react'
import type { FormSchema } from '@runilib/react-formbridge'
import { field, useFormBridgeWizard } from '@runilib/react-formbridge'

const steps = [
  {
    id: 'account',
    label: 'Account',
    schema: {
      email: field.email('Email').required(),
      password: field.password('Password').required(),
    } satisfies FormSchema,
  },
  {
    id: 'profile',
    label: 'Profile',
    schema: {
      firstName: field.text('First name').required(),
      country: field.select('Country').options(['FR','US','GB']).required(),
    } satisfies FormSchema,
    condition: (values) => values.email?.endsWith('@company.com'),
    optional: true,
  },
  {
    id: 'review',
    label: 'Review',
    schema: {} satisfies FormSchema,
  },
]

export function WizardPlayground() {
  const [submitted, setSubmitted] = useState<Record<string, unknown> | null>(null)

  const wizard = useFormBridgeWizard(steps, {
    validateOn: 'onBlur',
    revalidateOn: 'onChange',
    onSubmit: async (allValues) => {
      setSubmitted(allValues)
    },
  })

  if (!wizard.step) return null

  const { Form, fields } = wizard.currentStep

  return (
    <div style={{ fontFamily: 'sans-serif', padding: 20, background: '#f5f7fb' }}>
      <p style={{ marginTop: 0, color: '#4b5563' }}>
        Step <strong>{wizard.currentStepIndex + 1}</strong> / {wizard.totalSteps}
        {' · '}
        {wizard.step.label}
        {' · '}
        Progress: {wizard.progress}%
      </p>

      <p style={{ color: '#4b5563' }}>
        Visible steps: {wizard.visibleSteps.map((step) => step.id).join(' → ')}
      </p>

      <Form
        onSubmit={async () => {
          if (wizard.isLastStep) await wizard.submit()
          else await wizard.next()
        }}
      >
        {'email' in fields && <fields.email />}
        {'password' in fields && <fields.password />}
        {'firstName' in fields && <fields.firstName />}
        {'country' in fields && <fields.country />}
        {wizard.step.id === 'review' ? (
          <pre
            style={{
              marginTop: 0,
              padding: 12,
              borderRadius: 12,
              border: '1px solid #d6d9e0',
              background: '#fff',
              whiteSpace: 'pre-wrap',
            }}
          >
            {JSON.stringify(wizard.allValues, null, 2)}
          </pre>
        ) : null}

        <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
          {!wizard.isFirstStep && (
            <button type="button" onClick={wizard.prev}>
              Back
            </button>
          )}
          {wizard.step.optional ? (
            <button type="button" onClick={() => wizard.skip()}>
              Skip optional step
            </button>
          ) : null}
          <Form.Submit>{wizard.isLastStep ? 'Finish' : 'Next'}</Form.Submit>
        </div>
      </Form>

      <div
        style={{
          marginTop: 16,
          border: '1px solid #d6d9e0',
          borderRadius: 12,
          padding: 12,
          background: '#fff',
        }}
      >
        <strong>Completed steps</strong>
        <p style={{ marginBottom: 8 }}>
          {Array.from(wizard.completedSteps).join(', ') || 'None yet'}
        </p>
        <strong>Last submit</strong>
        <pre style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
          {JSON.stringify(submitted, null, 2)}
        </pre>
      </div>
    </div>
  )
}

export default WizardPlayground

Step shape

Each entry in the steps array is a WizardStep with the following shape:

MethodTypeDescription
idstringStable identifier - used for URL routing, persistence keys, goToStep(), and WizardStepChangeEvent.step.id. Keep it URL-safe
labelstringHuman-readable title rendered in step indicators, breadcrumbs, and accessible names. Safe to translate
schemaFormSchemaFormBridge schema owned by this step. Use {} satisfies FormSchema for pure review/confirmation screens
optional?booleanMarks the step as skippable. Required to enable wizard.skip(). Does not exclude the step from validation when the user stays on it
condition?(allValues) => booleanDynamic 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
1const 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:

MethodTypeDescription
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) => stringMaps 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?stringControlled 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?stringUncontrolled starting step id. Ignored when stepId is controlled or when a persisted snapshot restores a step. Defaults to steps[0].id
onStepChange?(event: WizardStepChangeEvent) => voidFires every time the active step changes. Navigation bridge + analytics/telemetry hook

Uncontrolled (default): single-page wizard

SignupWizard.tsxtsx
1const 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
1const { stepId } = useParams()
2const navigate = useNavigate()
3
4const 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:

MethodTypeDescription
stepWizardStepThe step the wizard is navigating to (always a member of visibleSteps)
indexnumber0-based index of step inside visibleSteps. Use for "Step N of M" labels
previousStepWizardStep | nullThe step the user is leaving. null only for initial "restore" / "fallback" events
previousIndexnumberIndex 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

ValueSource
'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
1onStepChange: ({ 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

MethodTypeDescription
currentStepUseFormBridgeReturn<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
stepWizardStep | nullActive step descriptor, or null during isHydrating / empty visibleSteps. Always guard if (!wizard.step) return null
currentStepIdstring | nullShorthand for step?.id. Safe to use as React key
currentStepIndexnumber0-based index inside visibleSteps, or -1 when step is null

Step catalog

MethodTypeDescription
totalStepsnumbervisibleSteps.length. Use for "Step {currentStepIndex + 1} / {totalSteps}"
allStepsWizardStep[]Raw array you passed in, including steps hidden by condition
visibleStepsWizardStep[]allSteps filtered through each step's condition. What every index-based API operates on
isFirstStepbooleanNo visible step before the active one. Disable your "Back" button
isLastStepbooleanNo visible step after the active one. Flip your primary button label to "Finish / Submit"

Progress

MethodTypeDescription
progressnumberRounded percentage Math.round(completedSteps.size / visibleSteps.length * 100)
completedStepsSet<string>Ids of steps whose validation has passed at least once. Not affected by prev() / goTo()
allValuesRecord<string, unknown>Merged values across the whole wizard: { ...accumulatedValues, ...currentStep.state.values }. Always reflects the live values the user just typed

Navigation

MethodTypeDescription
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() => voidSynchronously 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() => booleanAdvances one step only when the current step has optional: true and is not the last. Does not validate

Final submission

MethodTypeDescription
submit() => Promise<void>Validates, merges, calls options.onSubmit(allValues). On success flips isSubmitSuccess, clears every persisted draft. Errors route through onSubmitError
isSubmittingbooleantrue between entering submit() and resolution. Drive button spinners
isSubmitSuccessbooleantrue once onSubmit has resolved. Stays true until unmount - use to render the success screen
submitErrorstring | nullLast error message from onSubmitError (or default fallback). Cleared on next submit()

Hydration

MethodTypeDescription
isHydratingbooleanFirst 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
1if (wizard.isHydrating || !wizard.step) return <Spinner />
2
3const { Form, fields } = wizard.currentStep
4
5return (
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