javascript 146 lines · 4 tabs

Multi-Step Form Wizard in React With a useReducer State Machine

Shared by codesnips Jul 2026
4 tabs
export const initialState = (steps, initialData = {}) => ({
  steps,
  stepIndex: 0,
  data: initialData,
  errors: {},
});

export function wizardReducer(state, action) {
  switch (action.type) {
    case 'SET_FIELD': {
      const { name, value } = action;
      const errors = { ...state.errors };
      delete errors[name];
      return { ...state, data: { ...state.data, [name]: value }, errors };
    }
    case 'NEXT': {
      const step = state.steps[state.stepIndex];
      const errors = step.validate ? step.validate(state.data) : {};
      if (Object.keys(errors).length > 0) {
        return { ...state, errors };
      }
      const last = state.steps.length - 1;
      return { ...state, errors: {}, stepIndex: Math.min(state.stepIndex + 1, last) };
    }
    case 'BACK':
      return { ...state, errors: {}, stepIndex: Math.max(state.stepIndex - 1, 0) };
    default:
      return state;
  }
}
4 files · javascript Explain with highlit

A form wizard splits one long form into a sequence of focused steps while keeping all the collected data in a single source of truth. This snippet models the wizard as a small state machine driven by useReducer, which is a better fit than scattered useState calls because every transition (moving forward, going back, editing a field, recording validation errors) is expressed as an explicit action.

In wizardReducer, the state holds the current stepIndex, the merged data object, and a per-step errors map. The SET_FIELD action does a shallow merge into data and clears the error for that field, so touching an input immediately removes its stale message. NEXT first runs the active step's validate function; if it returns errors the reducer stores them and refuses to advance, otherwise it clamps stepIndex against the number of steps. BACK never re-validates, which matches the mental model that going backward should always be allowed. Keeping steps in state (rather than closing over them) lets the reducer stay a pure function of (state, action).

useWizard hook wraps the reducer and exposes a narrow, intention-revealing API — setField, next, back, plus derived booleans isFirst and isLast. Consumers never dispatch raw actions, which keeps the action shape an implementation detail and makes the hook easy to test in isolation. The steps array pairs a component with an optional validate, so validation lives next to the step it guards.

FormWizard is the orchestration layer. It renders the current step's component, feeds it the shared data and errors, and wires the field change handler through setField. The footer swaps a Back button and a Next/Submit button based on isLast, and submission only fires once the final step validates. AccountStep shows a leaf step: a pure controlled component that reports changes upward and reads its own error strings from props, holding no state of its own.

The trade-off is a little indirection up front, but the payoff is that adding, reordering, or removing a step is a one-line change to the steps array, and the wizard's behavior stays centralized and predictable.


Related snips

ruby
class SignupForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :account_name, :string
  attribute :email, :string

Shallow Controller, Deep Params: Form Object Pattern

rails activemodel form-object
by codesnips 3 tabs
javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["form"]
  static values = { delay: { type: Number, default: 250 } }

Debounced live search with Stimulus + Turbo Streams

rails hotwire stimulus
by codesnips 4 tabs
typescript
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
  timeout: 15000,

Axios API client with interceptors

react axios api
by Maya Patel 1 tab
typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface FilterState {
  search: string
  category: string | null

Zustand for lightweight state management

react zustand state-management
by Maya Patel 2 tabs
html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Form Validation Example</title>
  <style>

HTML forms with validation and accessibility

html forms validation
by Alex Chang 1 tab
erb
<%= form_with model: @project, data: { controller: 'disable-submit' } do |f| %>
  <%= f.text_field :name, class: 'rounded border p-2' %>
  <%= f.submit 'Save', data: { disable_submit_target: 'button' }, class: 'rounded bg-blue-600 px-3 py-2 text-white' %>
<% end %>

Disable submit button while Turbo form is submitting

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

Multi-Step Form Wizard in React With a useReducer State Machine — share card
Link copied