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;
}
}
import { useReducer, useCallback } from 'react';
import { wizardReducer, initialState } from './wizardReducer';
export function useWizard(steps, initialData) {
const [state, dispatch] = useReducer(wizardReducer, undefined, () =>
initialState(steps, initialData)
);
const setField = useCallback(
(name, value) => dispatch({ type: 'SET_FIELD', name, value }),
[]
);
const next = useCallback(() => dispatch({ type: 'NEXT' }), []);
const back = useCallback(() => dispatch({ type: 'BACK' }), []);
const step = steps[state.stepIndex];
return {
step,
data: state.data,
errors: state.errors,
stepIndex: state.stepIndex,
total: steps.length,
isFirst: state.stepIndex === 0,
isLast: state.stepIndex === steps.length - 1,
setField,
next,
back,
};
}
import React from 'react';
import { useWizard } from './useWizard';
import { AccountStep } from './AccountStep';
import { ProfileStep } from './ProfileStep';
import { ReviewStep } from './ReviewStep';
const steps = [
{
Component: AccountStep,
validate: (d) => {
const e = {};
if (!d.email) e.email = 'Email is required';
if (!d.password || d.password.length < 8) e.password = 'Min 8 characters';
return e;
},
},
{ Component: ProfileStep },
{ Component: ReviewStep },
];
export function FormWizard({ onComplete }) {
const w = useWizard(steps, { email: '', password: '' });
const Step = w.step.Component;
const handleNext = () => {
if (w.isLast) {
const errors = steps[w.stepIndex].validate?.(w.data) ?? {};
if (Object.keys(errors).length === 0) onComplete(w.data);
return;
}
w.next();
};
return (
<form onSubmit={(e) => e.preventDefault()}>
<p className="wizard-progress">
Step {w.stepIndex + 1} of {w.total}
</p>
<Step data={w.data} errors={w.errors} onChange={w.setField} />
<footer className="wizard-footer">
{!w.isFirst && (
<button type="button" onClick={w.back}>
Back
</button>
)}
<button type="button" onClick={handleNext}>
{w.isLast ? 'Submit' : 'Next'}
</button>
</footer>
</form>
);
}
import React from 'react';
export function AccountStep({ data, errors, onChange }) {
return (
<fieldset>
<legend>Account details</legend>
<label>
Email
<input
type="email"
value={data.email}
onChange={(e) => onChange('email', e.target.value)}
aria-invalid={Boolean(errors.email)}
/>
{errors.email && <span className="error">{errors.email}</span>}
</label>
<label>
Password
<input
type="password"
value={data.password}
onChange={(e) => onChange('password', e.target.value)}
aria-invalid={Boolean(errors.password)}
/>
{errors.password && <span className="error">{errors.password}</span>}
</label>
</fieldset>
);
}
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
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
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
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
<%= 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
Share this code
Here's the card — post it anywhere.