Skip to content

Multi-step wizards

Add a steps array to a FormConfig and FormRenderer switches from a single scrolling form to a stepper: one screen per step, Back/Next navigation, and an optional read-only review screen before submit. The fields array is unchanged — steps just assign existing field names to screens.

Step config shape

Each entry in steps is { title, fieldNames?, review?, visibleWhen? }. A step needs exactly one of fieldNames or review: true — the validator rejects a step with both or neither. fieldNames lists root field names owned by that step (group rows like team.0.role resolve back to the root name for step lookup). Only submit and hidden fields are exempt — they render automatically regardless of the current step and must not be listed in any step's fieldNames. Every other field — including static content — must be assigned to exactly one step, or config validation fails.

export const config: FormConfig = {
  id: "wizard-demo",
  fields: [
    { type: "text", name: "fullName", label: "Full name", required: true },
    { type: "email", name: "email", label: "Email", required: true },
    { type: "select", name: "plan", label: "Plan", required: true, options: [/* ... */] },
    { type: "submit", name: "submit", text: "Create account" },
  ],
  steps: [
    { title: "Account", fieldNames: ["fullName", "email"] },
    { title: "Plan", fieldNames: ["plan"] },
    { title: "Review", review: true },
  ],
};

Step gating

The Next button gates on form.trigger(currentStepFieldNames) never formState.isValid. The condition-aware resolver computes isValid across every currently-visible field in the whole form, not just the current step, so gating Next on it would block progress on step one because a required field on step three is still empty. On a failed trigger(), focus moves to the first invalid field on the current step so the error is announced to screen readers.

A step that owns no fieldNames is treated as vacuously valid — Next skips running the resolver for nothing. (The review step also owns no fields, but it renders the Submit button rather than Next, so this guard exists for fieldless steps generically.)

The submit button is the one deliberate exception to the rule above: it disables on !formState.isValid directly, because by the time you can reach it (the last step) that value correctly spans exactly the fields you can still edit.

Conditional steps

A step's own visibleWhen hides the entire step — same value-only ConditionSpec restriction as field visibleWhen (isValid is rejected there too, for the same feedback-loop reason — see Conditions). A hidden step's fields are treated exactly like condition-hidden fields everywhere: excluded from the validation schema, stripped from the submit payload, and skipped by the stepper — they don't appear in the step-number list and Next/Back never land on them.

steps: [
  { title: "Account", fieldNames: ["fullName", "email", "accountType"] },
  {
    title: "Company details",
    fieldNames: ["companyName", "vatNumber"],
    // Value operators only — same isValid restriction as field visibleWhen.
    visibleWhen: { field: "accountType", equals: "company" },
  },
  { title: "Review", review: true },
]

If the step you're currently viewing becomes hidden out from under you (its condition source changed), the stepper automatically moves to the nearest visible step — the next one if there is one, otherwise the previous one. The config validator also dev-warns if every step has a visibleWhen, since some value combination could then hide the entire wizard.

Review step

Set review: true instead of fieldNames to add a read-only summary screen. It shows every visible field from every earlier visiblestep (a hidden or later step contributes nothing), grouped by step with a per-step "Edit" button that jumps the stepper back there. Values are read live off form state, so editing an earlier step and returning to review always shows the current value, not a stale snapshot taken when you first reached it.

static and submitfields are dropped from the row list (nothing to review). Hidden-type fields don't appear as review rows either, but they still render on the review step's grid so their carried values keep flowing regardless of the active step. A groupfield's rows render as one card per array entry, using the group's inner field labels. Because a review step owns no fieldNames, it's exempt from the "every field must be assigned to a step" check described above.

Driving the step from your app

By default the wizard owns its own step. Pass step and onStepChangeto put it on routes instead — one URL per step, so the back button, deep links and the review screen's "Edit" buttons all become ordinary navigation.

const STEP_ROUTES = ["/apply/account", "/apply/details", "/apply/review"];

<FormRenderer
  config={config}
  onSubmit={submit}
  step={STEP_ROUTES.indexOf(pathname)}
  onStepChange={(step) => router.push(STEP_ROUTES[step])}
  stepperOrientation="vertical"
  autosave={{ key: "application" }}
  onDraftRestore={({ step }) => setRestoredFrom(step)}
/>

step sharesthe step, it doesn't own it — this is not a controlled <input value>. The wizard still moves itself when it must: Next advances once its own validation gate passes, a step that hides under the visitor bounces to the nearest visible one, and a server field error jumps to that field's step. Setting step asks the wizard to go somewhere; it goes, then reports through onStepChange where it actually landed. A host that ignores onStepChange still gets a working wizard — its own copy of the step just goes stale.

Two requests aren't honoured literally: an out-of-range index is clamped into [0, steps.length - 1], and an index whose step is hidden by visibleWhen redirects to the nearest visible step. onStepChange reports the real index whenever it differs from the step the wizard was already on — so step={99} on a fresh mount reports the last step, while step={-5} reports nothing, having clamped to the step it was already showing.

If your router can't give you a synchronous step, don't pass step at all. Use onStepChange on its own to push the URL and let the wizard own the step — you still get one route per step and a working back button, you just stop feeding the value back in. The only thing you give up is driving the wizard from outside: deep links into a step, and a browser Back the wizard should follow.

Derive step synchronously from the URL — as the snippet above does with pathname — rather than updating it after a navigation commits. A stepthat lags behind the wizard has two consequences. First, a return to the step you still hold isn't reported: hold step={1} while the visitor goes Next to 2 (reported) then Back to 1, and that last move matches the value you passed, so the engine stays silent — you and the wizard agree on the step, but no callback told you. Second, a late step pulls the visitor forward again: if your router lands step={2}a tick after the visitor already pressed Back, the wizard honours it. The engine can't tell a stale echo of its own report from a genuine browser-Back to that URL — both arrive as the same number.

onStepChange reports landings, not requests. It stays quiet for the step the wizard mounts on and for a step it reached because you passed it as step, so writing onStepChange={(s) => router.push(routes[s])} literally costs you no redundant navigation on load and no echo of your own prop. It does fire for every step the wizard chose itself: Next/Back, a review-step Edit, a server-error jump, a hidden-step bounce, and the two corrections above.

stepperOrientation="vertical" lays the step list out as a left rail beside the fields (from the tablet breakpoint up; narrower than that it stacks, as horizontal does). The list markup is identical either way — same <ol>, accessible name, aria-current="step" and focus move on step change — so only layout changes, never semantics. If you want a step change spoken as well as focused, render your own aria-live region from onStepChange: the engine deliberately ships no live region, because it would announce over the focus move it already performs and the wording is yours, not the engine's.

With autosave on, onDraftRestore fires once per restore so you can tell the visitor their progress came back. Its step is whatever step the restored draft recorded (undefined if it recorded none); the wizard moves there itself and reports it through onStepChange, so a router-backed host navigates to where the visitor left off — the restored step beats your step even when both arrive together. Autosave records the current step over its own internal channel rather than onStepChange, so the moves that callback deliberately stays silent about — the ones you asked for — are still persisted, and a router-driven wizard keeps a correct resume point.

Cross-step caveat: otp dependsOn

An otp field's dependsOnsource normally lives on the same step. If it's on a different step, the config validator dev-warns rather than errors — it works, because shouldUnregister stays falseso values persist while a step's fields are unmounted — but editing the source field while the otp field's step is unmounted defers re-verification until that step remounts. Keep the pair on one step unless that tradeoff is intentional.

Try it

Two fields on step one, one field on step two, then a review screen before submit.

  1. 1Account
  2. 2Plan
  3. 3Review
View config
{
  "id": "wizard-demo",
  "title": "Quick signup",
  "fields": [
    {
      "type": "text",
      "name": "fullName",
      "label": "Full name",
      "required": true
    },
    {
      "type": "email",
      "name": "email",
      "label": "Email",
      "required": true
    },
    {
      "type": "select",
      "name": "plan",
      "label": "Plan",
      "required": true,
      "options": [
        {
          "label": "Free",
          "value": "free"
        },
        {
          "label": "Pro",
          "value": "pro"
        }
      ]
    },
    {
      "type": "submit",
      "name": "submit",
      "text": "Create account"
    }
  ],
  "steps": [
    {
      "title": "Account",
      "fieldNames": [
        "fullName",
        "email"
      ]
    },
    {
      "title": "Plan",
      "fieldNames": [
        "plan"
      ]
    },
    {
      "title": "Review",
      "review": true
    }
  ]
}

For a fuller wizard — confirm-password via rules.matches, an email otp step with cross-step dependsOn, then review — see the multi-step signup example.