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;
}
import { createContext, useContext, useReducer, useMemo } from 'react';
import { wizardMachine, transition, canLeave } from './wizardMachine';
const WizardContext = createContext(null);
function reducer(state, action) {
switch (action.type) {
case 'PATCH':
return { ...state, data: { ...state.data, ...action.patch } };
case 'NEXT': {
const next = transition(state.state, 'NEXT', state.data);
if (next === state.state) return state;
return { ...state, state: next, history: [...state.history, state.state] };
}
case 'BACK': {
const prev = transition(state.state, 'BACK', state.data);
return { ...state, state: prev, history: state.history.slice(0, -1) };
}
default:
return state;
}
}
export function WizardProvider({ children, initialData = {} }) {
const [state, dispatch] = useReducer(reducer, {
state: wizardMachine.initial,
data: initialData,
history: [],
});
const value = useMemo(() => ({
step: state.state,
data: state.data,
canAdvance: canLeave(state.state, state.data),
canGoBack: state.history.length > 0,
patch: (patch) => dispatch({ type: 'PATCH', patch }),
next: () => dispatch({ type: 'NEXT' }),
back: () => dispatch({ type: 'BACK' }),
}), [state]);
return <WizardContext.Provider value={value}>{children}</WizardContext.Provider>;
}
export function useWizard() {
const ctx = useContext(WizardContext);
if (!ctx) throw new Error('useWizard must be used within a WizardProvider');
return ctx;
}
import { WizardProvider, useWizard } from './WizardContext';
import AccountStep from './steps/AccountStep';
import ProfileStep from './steps/ProfileStep';
import BillingStep from './steps/BillingStep';
import ReviewStep from './steps/ReviewStep';
const STEP_COMPONENTS = {
account: AccountStep,
profile: ProfileStep,
billing: BillingStep,
review: ReviewStep,
};
const STEP_ORDER = ['account', 'profile', 'billing', 'review'];
function WizardShell({ onComplete }) {
const { step, canAdvance, canGoBack, next, back } = useWizard();
const StepComponent = STEP_COMPONENTS[step];
const isLast = step === 'review';
return (
<form onSubmit={(e) => e.preventDefault()} className="wizard">
<ol className="wizard__progress">
{STEP_ORDER.map((id) => (
<li key={id} aria-current={id === step ? 'step' : undefined}>{id}</li>
))}
</ol>
<StepComponent />
<footer className="wizard__nav">
<button type="button" onClick={back} disabled={!canGoBack}>Back</button>
{isLast ? (
<button type="button" onClick={onComplete} disabled={!canAdvance}>Submit</button>
) : (
<button type="button" onClick={next} disabled={!canAdvance}>Continue</button>
)}
</footer>
</form>
);
}
export default function WizardForm({ initialData, onComplete }) {
return (
<WizardProvider initialData={initialData}>
<WizardShell onComplete={onComplete} />
</WizardProvider>
);
}
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
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
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
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
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
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
Share this code
Here's the card — post it anywhere.