Skip to content

Multi-Step Forms ​

@neutro/form is well-suited to multi-step wizards. The recommended pattern is a single createForm instance shared across all steps, with validate(paths) to scope validation per step and connect(..., { persist: true }) to retain values from fields that unmount between steps.


Single Form Instance Across Steps ​

Rather than creating one form per step and merging state at the end, create a single form at the top level with all fields. This avoids the complexity of synchronising multiple forms and gives you one getPayload() call at submission time.

ts
import { createForm } from '@neutro/form/core'

type WizardValues = {
  // Step 1 — personal info
  firstName: string
  lastName: string
  email: string
  // Step 2 — address
  street: string
  city: string
  country: string
  // Step 3 — payment
  cardNumber: string
  expiry: string
}

const form = createForm<WizardValues>({
  initialValues: {
    firstName: '', lastName: '', email: '',
    street: '', city: '', country: '',
    cardNumber: '', expiry: '',
  },
  validator: (values) => {
    const errors: Record<string, string> = {}
    if (!values.firstName) errors.firstName = 'Required'
    if (!values.lastName) errors.lastName = 'Required'
    if (!values.email.includes('@')) errors.email = 'Invalid email'
    if (!values.street) errors.street = 'Required'
    if (!values.city) errors.city = 'Required'
    if (!values.country) errors.country = 'Required'
    if (values.cardNumber.replace(/\s/g, '').length !== 16)
      errors.cardNumber = 'Must be 16 digits'
    if (!/^\d{2}\/\d{2}$/.test(values.expiry))
      errors.expiry = 'Format: MM/YY'
    return errors
  },
})

Scoped Validation Per Step ​

Use form.validate(paths) to validate only the fields belonging to the current step. This lets users move between steps without seeing errors for fields they haven't reached yet.

ts
const STEP_FIELDS: Record<number, string[]> = {
  1: ['firstName', 'lastName', 'email'],
  2: ['street', 'city', 'country'],
  3: ['cardNumber', 'expiry'],
}

async function goToNextStep(currentStep: number): Promise<boolean> {
  const paths = STEP_FIELDS[currentStep]

  // Validate only this step's fields
  await form.validate(paths)

  const state = form.getState()
  const hasStepErrors = paths.some((p) => p in state.errors)

  if (hasStepErrors) return false // stay on current step

  currentStep++
  return true
}

Persisting Fields That Unmount ​

When a step's DOM elements are removed (e.g. the component is unmounted), the MutationObserver clears their field state by default. Use persist: true so the values survive across step transitions:

tsx
// React example — Step 1 component
import { useEffect, useRef } from 'react'

function Step1({ form }) {
  const firstNameRef = useRef(null)
  const lastNameRef = useRef(null)
  const emailRef = useRef(null)

  useEffect(() => {
    const d1 = form.connect('firstName', firstNameRef.current, { persist: true })
    const d2 = form.connect('lastName', lastNameRef.current, { persist: true })
    const d3 = form.connect('email', emailRef.current, { persist: true })

    // Disconnect functions are called on unmount, but because persist: true,
    // the values remain available in getPayload()
    return () => { d1(); d2(); d3() }
  }, [form])

  return (
    <>
      <input ref={firstNameRef} onInput={(e) =>
        form.set('firstName', e.target.value, { touch: true })
      } />
      <input ref={lastNameRef} onInput={(e) =>
        form.set('lastName', e.target.value, { touch: true })
      } />
      <input ref={emailRef} type="email" onInput={(e) =>
        form.set('email', e.target.value, { touch: true })
      } />
    </>
  )
}

Alternatively, use the useFormConnect hook from the React adapter, which handles the useEffect setup for you.


Collecting the Final Payload ​

At the end of the wizard, validate all fields and call getPayload() to get the submission object:

ts
async function submitWizard() {
  // Validate everything
  await form.validate()

  const state = form.getState()
  if (Object.keys(state.errors).length > 0) {
    // Navigate back to the first step with errors
    return
  }

  // getPayload() returns only connected + persisted paths (falling back to
  // the full values object if nothing is connected or persisted) —
  // all persisted step fields are included even though they're now unmounted
  const payload = form.getPayload()

  await fetch('/api/submit', {
    method: 'POST',
    body: JSON.stringify(payload),
  })
}

Resetting Between Sessions ​

form.reset() restores all values to initialValues and clears errors, touched, and dirty maps. Call it when the user abandons the wizard and restarts:

ts
function handleStartOver() {
  form.reset()
  setCurrentStep(1)
}

You can also re-seed the form with data loaded from a draft save:

ts
async function loadDraft(draftId: string) {
  const draft = await fetch(`/api/drafts/${draftId}`).then((r) => r.json())
  form.reset(draft) // draft values become the new baseline
  setCurrentStep(1)
}

Full Pattern Summary ​

  1. Create one createForm instance with all fields across all steps.
  2. Use form.validate(stepPaths) to validate each step before advancing.
  3. Connect DOM elements with persist: true so values survive step transitions.
  4. Use form.getPayload() at submission to collect only the relevant field data.
  5. Use form.reset(newValues?) to support drafts or start-over flows.

Tip — validation modes: Use validationMode to suppress inline validation noise in multi-step flows. For example, set 'onSubmitOnly' globally and override only the fields where immediate feedback is valuable:

ts
createForm({
  initialValues: { ... },
  validationMode: {
    default: 'onSubmitOnly', // no inline errors by default
    fields: {
      email: 'onBlur',     // email checks on blur are useful everywhere
    },
  },
})