javascript 139 lines · 3 tabs

Multi-Step Wizard Form With a Context-Driven State Machine in React

Shared by codesnips Aug 2026
3 tabs
export const wizardMachine = {
  initial: 'account',
  states: {
    account: {
      next: 'profile',
      canLeave: (data) => Boolean(data.email && data.password),
    },
    profile: {
      next: 'billing',
      prev: 'account',
      canLeave: (data) => Boolean(data.fullName),
    },
    billing: {
      next: 'review',
      prev: 'profile',
      canLeave: (data) => Boolean(data.plan),
    },
    review: {
      prev: 'billing',
      canLeave: () => true,
    },
  },
};

export function canLeave(stateId, data) {
  const state = wizardMachine.states[stateId];
  return state.canLeave ? state.canLeave(data) : true;
}

export function transition(stateId, event, data) {
  const state = wizardMachine.states[stateId];
  if (!state) return stateId;

  if (event === 'NEXT') {
    if (!state.next || !canLeave(stateId, data)) return stateId;
    return state.next;
  }
  if (event === 'BACK') {
    return state.prev || stateId;
  }
  return stateId;
}
3 files · javascript Explain with highlit

This snippet builds a multi-step wizard as an explicit finite state machine driven by React context, rather than an ad-hoc currentStep integer scattered across components. The core idea is that a wizard is really a graph of states with allowed transitions, so encoding those transitions in one place makes navigation predictable and prevents illegal jumps (for example skipping validation on a required step).

In wizardMachine.js, the machine is defined declaratively: each state names its next and prev targets and an optional canLeave guard that inspects the accumulated form data. The transition function is a pure reducer helper — given the current state id and an event, it returns the next state id or the same one if the guard fails or the event is undefined for that state. Keeping this pure means it can be unit-tested without React and reused on the server.

In WizardContext.jsx, a useReducer holds the whole wizard runtime: the active state, merged data, and a history stack used for backtracking. The reducer handles NEXT, BACK, and PATCH events; NEXT consults transition and only advances when the guard permits, while PATCH shallow-merges field updates so each step contributes to one shared object. WizardProvider exposes this through context, and the useWizard hook throws when used outside the provider — a common guard that surfaces wiring mistakes early. The derived canAdvance value lets buttons disable themselves without duplicating guard logic.

In WizardForm.jsx, the UI stays thin: a STEP_COMPONENTS map renders the component for the current state, and the footer wires back and next to dispatchers. Because the machine owns transitions, the view never computes which step is next; it only emits events. This separation is the main trade-off — more upfront structure in exchange for navigation that is declarative, guarded, and easy to extend by adding a state and its edges. It shines for onboarding flows, checkouts, and anything where steps have conditional branches or must not be skipped.


Related snips

typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
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
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
javascript
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"

const application = Application.start()
application.debug = false

Disable submit button while Turbo form is submitting

rails hotwire stimulus
by codesnips 3 tabs

Share this code

Here's the card — post it anywhere.

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