react-data-form

Multi-step forms

Turning the same description into a wizard, with per-step validation.

A wizard is a grouped form walked one section at a time. The field descriptions do not change at all — only the entry point and two of the form's components.

createStepForm#

ProjectWizard.tsx
import { createStepForm } from "react-data-form/step"

const form = createStepForm({
  label: { title: "New project", submit: "Create the project" },
  groupOption: {
    itemGroups: [
      { group: "type", title: "What are we building?", order: 10 },
      { group: "details", title: "The details", order: 20 },
      {
        group: "team",
        title: "Who is on it?",
        order: 30,
        // Runs before moving on — reserve a slug, check a quota…
        onNextStep: async ({ form }) => {
          await api.post("/projects/precheck", form.data)
          return form
        },
      },
    ],
  },
  inputs: {
    kind:    { label: "Project type", groups: ["type"], controller: SelectCardInputController, valueOptions: KINDS },
    name:    { label: "Name", groups: ["details"], required: true },
    summary: { label: "Summary", groups: ["details"] },
    lead:    { label: "Project lead", groups: ["team"] },
  },
})

A three-step form — try submitting with no name

createStepForm builds the form, applies the lowest-order step, and swaps two components: formSubmitAction becomes the step navigation, and formDecorator the progress header.

Validation per step#

Two things happen that an ordinary form does not do:

  • Moving forward validates the current step. goToNextStep runs the validators and refuses to advance while a field is in violation — so an error never scrolls off behind the reader.
  • A failed submit walks back. When the final submit raises a violation on a field belonging to an earlier step, the wizard returns to the first step holding one, instead of showing a message about a field that is not on screen.

This is why the last step is not enough

Server-side violations arrive after the whole payload is sent. Without that walk-back the reader would be told the form is invalid while looking at a page where everything is filled in.

Doing work between steps#

onNextStep is awaited before the wizard advances, and can return a modified form. That is the hook for anything the next step depends on — reserving an identifier, checking a quota, fetching the options of a field further along.

onNextStep: async ({ step, form }) => {
  const { data } = await api.post("/projects/precheck", form.data)

  // Fill the next step's options from what the server just answered.
  form.inputs.lead.valueOptions = valueOptionMapper(data.members, "name", "@id")
  return form
}

It exists in two places: on groupOption, where it runs between every pair of steps, and on a single step, where it runs only when leaving that one. Both run, the shared one first.

useFormStep#

The default navigation covers most wizards. When it does not, drive your own from the hook — it works anywhere inside the form's provider.

import { useFormStep } from "react-data-form/step"

function StepFooter() {
  const { currentStep, steps, progress, goToNextStep, goToPrevStep, onSubmit } =
    useFormStep()

  return (
    <footer>
      <progress value={progress} max={100} />
      <button onClick={goToPrevStep}>Back</button>
      <button onClick={goToNextStep}>Next</button>
    </footer>
  )
}
NameTypeDefaultDescription
currentStepFormStep | undefinedThe step on screen.
nextStep / previousStepFormStep | undefinedIts neighbours, or undefined at either end.
stepsFormStep[]Every step, sorted by order.
progressnumber0–100, from the current step's position.
goToNextStep() => voidValidates, runs onNextStep, then advances.
goToPrevStep() => voidGoes back. No validation — going back is never blocked.
onSubmit() => voidSubmits, and walks back to the first step in violation.
formFormWithStepsBuildThe form itself, should you need to read it.

Step options#

A step is an item group with one extra key, so everything on the groups page applies — title, description, icon, order.

NameTypeDefaultDescription
groupOption.itemGroupsrequiredFormStep[]The steps. Empty throws rather than rendering nothing.
groupOption.currentStepFormStepWhere the wizard is. Set for you; read it if you need to.
groupOption.hideStepNumberbooleanHides the “2 / 4” counter in the header.
groupOption.onNextStep({ step, form }) => Promise<Form>Runs between every pair of steps.
step.onNextStep({ step, form }) => Promise<Form>Runs when leaving that step in particular.