export const TOTAL_STEPS = 3;
export interface WizardData {
email: string;
password: string;
fullName: string;
company: string;
plan: "free" | "pro" | "team";
}
export interface WizardState {
step: number;
data: Partial<WizardData>;
}
export type WizardAction =
| { type: "NEXT"; patch: Partial<WizardData> }
| { type: "BACK" }
| { type: "GOTO"; step: number }
| { type: "RESET" };
export const initialState: WizardState = { step: 0, data: {} };
const clamp = (n: number) => Math.max(0, Math.min(n, TOTAL_STEPS - 1));
export function wizardReducer(state: WizardState, action: WizardAction): WizardState {
switch (action.type) {
case "NEXT":
return {
step: clamp(state.step + 1),
data: { ...state.data, ...action.patch },
};
case "BACK":
return { ...state, step: clamp(state.step - 1) };
case "GOTO":
// only allow jumping to a step at or before the current one
if (action.step > state.step) return state;
return { ...state, step: clamp(action.step) };
case "RESET":
return initialState;
default:
return state;
}
}
import { useReducer, useCallback, useMemo } from "react";
import {
wizardReducer,
initialState,
TOTAL_STEPS,
WizardData,
} from "./wizardReducer";
export function useWizard() {
const [state, dispatch] = useReducer(wizardReducer, initialState);
const patch = useCallback(
(data: Partial<WizardData>) => dispatch({ type: "NEXT", patch: data }),
[]
);
const back = useCallback(() => dispatch({ type: "BACK" }), []);
const goto = useCallback(
(step: number) => dispatch({ type: "GOTO", step }),
[]
);
const reset = useCallback(() => dispatch({ type: "RESET" }), []);
const flags = useMemo(
() => ({
isFirst: state.step === 0,
isLast: state.step === TOTAL_STEPS - 1,
progress: Math.round((state.step / (TOTAL_STEPS - 1)) * 100),
}),
[state.step]
);
return { state, patch, back, goto, reset, ...flags };
}
import { useState } from "react";
import { WizardData } from "./wizardReducer";
type AccountSlice = Pick<WizardData, "email" | "password">;
interface Props {
data: Partial<AccountSlice>;
onNext: (patch: AccountSlice) => void;
}
export function AccountStep({ data, onNext }: Props) {
const [email, setEmail] = useState(data.email ?? "");
const [password, setPassword] = useState(data.password ?? "");
const [error, setError] = useState<string | null>(null);
function submit(e: React.FormEvent) {
e.preventDefault();
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
return setError("Enter a valid email address.");
}
if (password.length < 8) {
return setError("Password must be at least 8 characters.");
}
setError(null);
onNext({ email, password });
}
return (
<form onSubmit={submit} noValidate>
<label>
Email
<input value={email} onChange={(e) => setEmail(e.target.value)} />
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
{error && <p role="alert">{error}</p>}
<button type="submit">Continue</button>
</form>
);
}
import { useWizard } from "./useWizard";
import { AccountStep } from "./AccountStep";
import { ProfileStep } from "./ProfileStep";
import { PlanStep } from "./PlanStep";
import { WizardData } from "./wizardReducer";
interface Props {
onComplete: (data: WizardData) => void;
}
export function WizardForm({ onComplete }: Props) {
const { state, patch, back, isFirst, progress } = useWizard();
const { step, data } = state;
function finish(finalPatch: Partial<WizardData>) {
onComplete({ ...data, ...finalPatch } as WizardData);
}
return (
<div className="wizard">
<progress value={progress} max={100} />
{step === 0 && <AccountStep data={data} onNext={patch} />}
{step === 1 && <ProfileStep data={data} onNext={patch} />}
{step === 2 && <PlanStep data={data} onSubmit={finish} />}
{!isFirst && (
<button type="button" className="back" onClick={back}>
Back
</button>
)}
</div>
);
}
A multi-step wizard is essentially a small state machine: the form advances through a fixed sequence of steps, each step owns a slice of the accumulated data, and navigation must be constrained so a user cannot skip ahead into an invalid state. This snippet models that machine with a typed useReducer, which keeps every transition explicit and every piece of collected data statically checked.
In wizardReducer.ts, the shared WizardData type describes the full payload the wizard will eventually submit, while WizardState tracks the current step index alongside a Partial<WizardData> — partial because early steps have only filled in some fields. The WizardAction type is a discriminated union keyed on type, so the reducer's switch narrows each action to its own payload shape. NEXT merges the step's partial patch into data and clamps the index against TOTAL_STEPS so it can never run past the end; BACK clamps at zero; GOTO allows backward jumps to already-completed steps. Because the union is closed, TypeScript flags any unhandled action, making the machine's surface area obvious.
useWizard.ts wraps the reducer in a custom hook and derives convenience flags — isFirst, isLast, and a patch helper that dispatches a NEXT with the current step's data. Centralizing this logic means step components stay dumb: they render fields and call patch, never touching indices directly. The hook also exposes progress for a progress bar, computed from state.step and TOTAL_STEPS.
AccountStep.tsx shows a single step component. It receives the slice of data it cares about and a typed onNext callback, performs local field validation before advancing, and only submits a Partial<WizardData> matching the fields it owns. Keeping validation local to each step avoids a monolithic validator and matches how real forms fail — one screen at a time.
WizardForm.tsx is the orchestrator: it renders the current step by index, passes the relevant data slice, and wires navigation to the hook. The trade-off of this pattern is some upfront typing ceremony, but it pays off in refactor safety — adding a step or field forces every affected callsite to update. It fits any onboarding, checkout, or setup flow where correctness of accumulated state matters more than raw brevity.
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.