10x-media plugins
Form Builder

Multi-step forms

Layer a serializable step graph over a form, with conditional branching.

A form document can carry an optional flow: a step graph over its flat field list. With no flow (or fewer than two steps) <Form> behaves as a single-step form, so the feature is adopted incrementally with no schema change.

The flow model

import type { FormFlow } from '@10x-media/form-builder/types'

const flow: FormFlow = {
  steps: [
    { id: 'contact', title: 'Contact details', fields: ['name', 'email'] },
    {
      id: 'message',
      title: 'Your message',
      fields: ['subject', 'body'],
      transitions: [{ when: { subject: { equals: 'urgent' } }, to: 'urgent-confirm' }],
      next: 'confirm',
    },
    { id: 'urgent-confirm', title: 'Confirm urgent request', fields: ['confirmUrgent'], next: null },
    { id: 'confirm', title: 'Review', fields: ['consent'] },
  ],
}

Each step groups fields by key: a field's machine name, or, for bare blocks such as message, the block row id Payload assigns it. Named fields therefore read exactly as above, and nameless copy is assignable to a step like anything else. transitions are evaluated in order against the current answers using the same condition engine as visibleWhen; the first match wins and takes precedence over next. With no match, next decides:

nextMeaning
unsetFall through to the next step in array order; the last step ends the form
nullExplicit end of form, regardless of position
a step idJump to that step

Above, contact falls through to message, message jumps to confirm (skipping urgent-confirm) unless the urgent transition matches, urgent-confirm ends the form explicitly, and confirm ends it as the last step. Transitions see effective values, including computed calculation fields, so a flow can branch on a quiz score or a running total.

Authoring in the admin

A form's header carries a Multi-step checkbox; turning it on reveals a Flow tab holding the flow builder (off, the tab and multistep stay hidden, and <Form> renders as a single page regardless of any flow data already stored). Flows are authored with the flow builder and stored as this serializable structure, so the client and server reason about the identical graph. Authors work entirely with step titles: step ids are generated automatically and never surface in the UI (a step without a title displays as Step 1, Step 2, and so on). Anywhere a step is referenced, the next step, a transition target, the label is its title.

The same tab carries the multi-step Next/Previous button labels, at the bottom, once the flow has at least one step; see Form-level buttons.

Field assignment is exclusive: each form field belongs to at most one step, and assigning a field to a step removes it from whichever step held it before. In the step's field picker and the unassigned-fields list, a named field is labeled with its authored label (falling back to its machine name when unset), and a bare block such as message is labeled with a short snippet of its authored content, falling back to its translated type label (numbered when the form holds more than one) only when it has none. The builder lists the form's unassigned fields so nothing silently falls out of the flow, and the save-time normalizer enforces the same invariant (a field appearing in multiple steps keeps only its first occurrence, and keys matching no current field are dropped). A flow that collapses to fewer than two valid steps is rejected with a validation error rather than silently discarded.

A step whose every field has since been deleted from the form keeps its place rather than being dropped: removing it would strand the next and transition targets aimed at it, and a host may render its own content for that step off useFormStep(). Deleting a step through the builder is the explicit path, and it cascades: every next aimed at the deleted step is cleared (falling through in order again) and every transition targeting it is dropped, so no edge points at a step that is gone.

Renderer behavior

<Form> reads flow from the document and drives it automatically:

  • renders only the current step's fields,
  • shows Back and Next between the first and last steps (labels overridable via nextLabel / prevLabel),
  • validates the current step's visible fields before advancing; errors block progress,
  • shows Submit on the terminal step,
  • submits all accumulated answers as one submission.

<FormSteps> progress indicator

<FormSteps> renders the flow as an ordered list with the current step marked (aria-current="step" plus an fb-form-steps__step--current class), using each step's title with the same numbered-step fallback as the admin builder. It reads the form context, so it works inside the default layout and custom children layouts alike, and renders nothing for single-step forms:

import { Form, FormSteps, FormControls } from '@10x-media/form-builder/react'

<Form form={form}>
  <FormSteps />
  {/* your fields */}
  <FormControls />
</Form>

It carries no styling beyond fb-form-steps classes; for fully custom progress UIs drop to the hook below.

useFormStep for custom step UIs

import { useFormStep } from '@10x-media/form-builder/react'

function StepProgress() {
  const { stepIndex, stepCount, isTerminal } = useFormStep()
  return <p>Step {stepIndex + 1} of {stepCount}{isTerminal ? ' (final)' : null}</p>
}

useFormStep() returns flow, currentStepId, stepIndex, stepCount, isFirst, isTerminal, goNext(), and goBack(). stepIndex/stepCount follow the declared order, which reads exactly for linear flows; for branching flows derive progress from currentStepId (skipped steps make the raw index misleading).

The flow engine

The graph logic is exported as four pure functions (no React, no DOM) from ./react, so custom UIs never reimplement it:

ExportSignature
firstStepId(flow) => string | undefined
resolveNextStepId(flow, currentId, answers) => string | undefined
isTerminalStepId(flow, currentId, answers) => boolean
stepFieldNames(flow, id) => string[]

On this page