typescript 158 lines · 4 tabs

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

Shared by codesnips Aug 2026
4 tabs
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;
  }
}
4 files · typescript Explain with highlit

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

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.

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