React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

formsuseReducermulti-stepwizardstate-managementvalidation

useReducer Multi-Step Forms

Manage complex multi-step form state with useReducer — explicit actions like SET_FIELD, NEXT_STEP, and VALIDATE make state transitions predictable, testable, and easy to debug without reaching for external libraries.

Recipe

Quick-reference recipe card — copy-paste ready.

"use client";
 
import { useReducer } from "react";
 
interface FormState {
  step: number;
  fields: Record<string, string>;
  errors: Record<string, string>;
}
 
type FormAction =
  | { type: "SET_FIELD"; name: string; value: string }
  | { type: "NEXT_STEP" }
  | { type: "PREV_STEP" }
  | { type: "VALIDATE"; errors: Record<string, string> }
  | { type: "RESET" };
 
function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case "SET_FIELD":
      return { ...state, fields: { ...state.fields, [action.name]: action.value }, errors: {} };
    case "NEXT_STEP":
      return { ...state, step: state.step + 1, errors: {} };
    case "PREV_STEP":
      return { ...state, step: Math.max(0, state.step - 1) };
    case "VALIDATE":
      return { ...state, errors: action.errors };
    case "RESET":
      return { step: 0, fields: {}, errors: {} };
  }
}

When to reach for this: When your form has 3+ steps, cross-step validation, or state transitions complex enough that juggling multiple useState calls becomes error-prone.

Working Example

"use client";
 
import { useReducer } from "react";
 
// --- Types ---
 
interface FormState {
  step: number;
  totalSteps: number;
  fields: {
    name: string;
    email: string;
    company: string;
    role: string;
    plan: string;
    cardNumber: string;
    agree: boolean;
  };
  errors: Record<string, string>;
  submitted: boolean;
}
 
type FormAction =
  | { type: "SET_FIELD"; name: keyof FormState["fields"]; value: string | boolean }
  | { type: "NEXT_STEP" }
  | { type: "PREV_STEP" }
  | { type: "VALIDATE"; errors: Record<string, string> }
  | { type: "SUBMIT" }
  | { type: "RESET" };
 
// --- Initial state ---
 
const initialState: FormState = {
  step: 0,
  totalSteps: 3,
  fields: {
    name: "",
    email: "",
    company: "",
    role: "",
    plan: "free",
    cardNumber: "",
    agree: false,
  },
  errors: {},
  submitted: false,
};
 
// --- Reducer (pure function — easy to unit test) ---
 
function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case "SET_FIELD":
      return {
        ...state,
        fields: { ...state.fields, [action.name]: action.value },
        errors: { ...state.errors, [action.name]: "" },
      };
    case "NEXT_STEP":
      return { ...state, step: state.step + 1, errors: {} };
    case "PREV_STEP":
      return { ...state, step: Math.max(0, state.step - 1), errors: {} };
    case "VALIDATE":
      return { ...state, errors: action.errors };
    case "SUBMIT":
      return { ...state, submitted: true };
    case "RESET":
      return initialState;
  }
}
 
// --- Per-step validation ---
 
function validateStep(step: number, fields: FormState["fields"]): Record<string, string> {
  const errors: Record<string, string> = {};
 
  if (step === 0) {
    if (!fields.name.trim()) errors.name = "Name is required";
    if (!fields.email.includes("@")) errors.email = "Valid email is required";
  }
 
  if (step === 1) {
    if (!fields.company.trim()) errors.company = "Company is required";
    if (!fields.role.trim()) errors.role = "Role is required";
  }
 
  if (step === 2) {
    if (fields.plan !== "free" && fields.cardNumber.length < 16)
      errors.cardNumber = "Valid card number is required for paid plans";
    if (!fields.agree) errors.agree = "You must agree to the terms";
  }
 
  return errors;
}
 
// --- Component ---
 
export default function MultiStepSignup() {
  const [state, dispatch] = useReducer(formReducer, initialState);
  const { step, totalSteps, fields, errors, submitted } = state;
 
  function handleNext() {
    const stepErrors = validateStep(step, fields);
    if (Object.keys(stepErrors).length > 0) {
      dispatch({ type: "VALIDATE", errors: stepErrors });
      return;
    }
    if (step < totalSteps - 1) {
      dispatch({ type: "NEXT_STEP" });
    } else {
      dispatch({ type: "SUBMIT" });
    }
  }
 
  if (submitted) {
    return (
      <div className="rounded-lg bg-green-50 p-8 text-center">
        <h2 className="text-xl font-semibold text-green-800">Welcome, {fields.name}!</h2>
        <p className="mt-2 text-green-600">Your {fields.plan} plan is active.</p>
        <button
          onClick={() => dispatch({ type: "RESET" })}
          className="mt-4 rounded bg-green-600 px-4 py-2 text-white"
        >
          Start Over
        </button>
      </div>
    );
  }
 
  return (
    <div className="mx-auto max-w-md">
      {/* Progress bar */}
      <div className="mb-6 flex gap-2">
        {Array.from({ length: totalSteps }, (_, i) => (
          <div
            key={i}
            className={`h-2 flex-1 rounded ${i <= step ? "bg-blue-600" : "bg-gray-200"}`}
          />
        ))}
      </div>
 
      {/* Step 0: Personal Info */}
      {step === 0 && (
        <div className="space-y-4">
          <h2 className="text-lg font-semibold">Personal Information</h2>
          <div>
            <input
              type="text"
              placeholder="Full name"
              value={fields.name}
              onChange={(e) => dispatch({ type: "SET_FIELD", name: "name", value: e.target.value })}
              className="w-full rounded border p-2"
            />
            {errors.name && <p className="mt-1 text-sm text-red-600">{errors.name}</p>}
          </div>
          <div>
            <input
              type="email"
              placeholder="Email"
              value={fields.email}
              onChange={(e) => dispatch({ type: "SET_FIELD", name: "email", value: e.target.value })}
              className="w-full rounded border p-2"
            />
            {errors.email && <p className="mt-1 text-sm text-red-600">{errors.email}</p>}
          </div>
        </div>
      )}
 
      {/* Step 1: Company Info */}
      {step === 1 && (
        <div className="space-y-4">
          <h2 className="text-lg font-semibold">Company Details</h2>
          <div>
            <input
              type="text"
              placeholder="Company"
              value={fields.company}
              onChange={(e) =>
                dispatch({ type: "SET_FIELD", name: "company", value: e.target.value })
              }
              className="w-full rounded border p-2"
            />
            {errors.company && <p className="mt-1 text-sm text-red-600">{errors.company}</p>}
          </div>
          <div>
            <input
              type="text"
              placeholder="Role"
              value={fields.role}
              onChange={(e) => dispatch({ type: "SET_FIELD", name: "role", value: e.target.value })}
              className="w-full rounded border p-2"
            />
            {errors.role && <p className="mt-1 text-sm text-red-600">{errors.role}</p>}
          </div>
        </div>
      )}
 
      {/* Step 2: Plan & Payment */}
      {step === 2 && (
        <div className="space-y-4">
          <h2 className="text-lg font-semibold">Choose Plan</h2>
          <select
            value={fields.plan}
            onChange={(e) => dispatch({ type: "SET_FIELD", name: "plan", value: e.target.value })}
            className="w-full rounded border p-2"
          >
            <option value="free">Free</option>
            <option value="pro">Pro — $19/mo</option>
            <option value="enterprise">Enterprise — $99/mo</option>
          </select>
          {fields.plan !== "free" && (
            <div>
              <input
                type="text"
                placeholder="Card number"
                value={fields.cardNumber}
                onChange={(e) =>
                  dispatch({ type: "SET_FIELD", name: "cardNumber", value: e.target.value })
                }
                className="w-full rounded border p-2"
              />
              {errors.cardNumber && (
                <p className="mt-1 text-sm text-red-600">{errors.cardNumber}</p>
              )}
            </div>
          )}
          <label className="flex items-center gap-2">
            <input
              type="checkbox"
              checked={fields.agree as boolean}
              onChange={(e) =>
                dispatch({ type: "SET_FIELD", name: "agree", value: e.target.checked })
              }
            />
            <span className="text-sm">I agree to the terms of service</span>
          </label>
          {errors.agree && <p className="mt-1 text-sm text-red-600">{errors.agree}</p>}
        </div>
      )}
 
      {/* Navigation */}
      <div className="mt-6 flex justify-between">
        <button
          onClick={() => dispatch({ type: "PREV_STEP" })}
          disabled={step === 0}
          className="rounded bg-gray-200 px-4 py-2 disabled:opacity-50"
        >
          Back
        </button>
        <button onClick={handleNext} className="rounded bg-blue-600 px-4 py-2 text-white">
          {step === totalSteps - 1 ? "Submit" : "Next"}
        </button>
      </div>
    </div>
  );
}

What this demonstrates:

  • All form state in a single useReducer — step index, field values, errors, and submission status
  • Per-step validation that runs only on "Next" — users aren't blocked by errors on future steps
  • SET_FIELD clears the error for that specific field, giving inline feedback
  • The reducer is a pure function with no side effects, making it trivially unit-testable
  • RESET action returns to initialState — no risk of stale partial state

Deep Dive

How It Works

  • useReducer accepts a pure reducer function and an initial state, returning the current state and a dispatch function
  • Every state change is an explicit action with a type string — you can log, replay, or undo actions
  • The reducer centralizes all state transitions in one place, eliminating scattered setState calls that can get out of sync
  • React batches multiple dispatches in the same event handler into a single re-render

Why useReducer Over useState for Multi-Step Forms

ConcernuseState (multiple)useReducer
State transitionsSpread across handlersCentralized in reducer
TestingMust mount componentTest reducer as pure function
DebuggingCheck each setterLog dispatched actions
Coordinated updatesRisk of partial updatesSingle atomic dispatch
Adding new fieldsAnother useState callAdd to state interface

The Action Pattern

Define a discriminated union for type-safe actions:

type FormAction =
  | { type: "SET_FIELD"; name: string; value: string }
  | { type: "NEXT_STEP" }
  | { type: "PREV_STEP" }
  | { type: "SET_ERRORS"; errors: Record<string, string> }
  | { type: "SUBMIT" }
  | { type: "RESET" };

TypeScript narrows the action inside each case branch — action.name is only accessible inside the SET_FIELD case.

Combining with Server Actions

"use client";
 
import { useReducer, useTransition } from "react";
import { submitSignup } from "./actions";
 
function MultiStepForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);
  const [isPending, startTransition] = useTransition();
 
  async function handleSubmit() {
    const errors = validateStep(state.step, state.fields);
    if (Object.keys(errors).length > 0) {
      dispatch({ type: "VALIDATE", errors });
      return;
    }
 
    startTransition(async () => {
      const result = await submitSignup(state.fields);
      if (result.errors) {
        dispatch({ type: "VALIDATE", errors: result.errors });
      } else {
        dispatch({ type: "SUBMIT" });
      }
    });
  }
 
  return (
    <button onClick={handleSubmit} disabled={isPending}>
      {isPending ? "Submitting..." : "Submit"}
    </button>
  );
}

Variations

Branching wizard (conditional steps):

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case "NEXT_STEP": {
      // Skip payment step if plan is "free"
      const nextStep = state.step === 1 && state.fields.plan === "free"
        ? state.step + 2
        : state.step + 1;
      return { ...state, step: nextStep, errors: {} };
    }
    // ... other cases
  }
}

Undo/redo with action history:

interface FormStateWithHistory {
  past: FormState[];
  present: FormState;
  future: FormState[];
}
 
type HistoryAction = FormAction | { type: "UNDO" } | { type: "REDO" };
 
function undoableReducer(state: FormStateWithHistory, action: HistoryAction): FormStateWithHistory {
  if (action.type === "UNDO") {
    const previous = state.past[state.past.length - 1];
    if (!previous) return state;
    return {
      past: state.past.slice(0, -1),
      present: previous,
      future: [state.present, ...state.future],
    };
  }
  if (action.type === "REDO") {
    const next = state.future[0];
    if (!next) return state;
    return {
      past: [...state.past, state.present],
      present: next,
      future: state.future.slice(1),
    };
  }
  return {
    past: [...state.past, state.present],
    present: formReducer(state.present, action),
    future: [],
  };
}

Lazy initialization for saved drafts:

function initFromStorage(defaultState: FormState): FormState {
  if (typeof window === "undefined") return defaultState;
  const saved = localStorage.getItem("signup-draft");
  return saved ? JSON.parse(saved) : defaultState;
}
 
const [state, dispatch] = useReducer(formReducer, initialState, initFromStorage);

TypeScript Notes

// Strongly typed field names — catch typos at compile time
interface SignupFields {
  name: string;
  email: string;
  plan: "free" | "pro" | "enterprise";
}
 
type SetFieldAction = {
  [K in keyof SignupFields]: { type: "SET_FIELD"; name: K; value: SignupFields[K] };
}[keyof SignupFields];
 
// Now dispatch({ type: "SET_FIELD", name: "plan", value: "invalid" }) is a type error
 
// Typed dispatch context to avoid prop drilling
import { createContext, useContext, type Dispatch } from "react";
 
const FormDispatchContext = createContext<Dispatch<FormAction> | null>(null);
 
function useFormDispatch(): Dispatch<FormAction> {
  const dispatch = useContext(FormDispatchContext);
  if (!dispatch) throw new Error("useFormDispatch must be used within FormProvider");
  return dispatch;
}

Unit Testing the Reducer

import { describe, it, expect } from "vitest";
 
describe("formReducer", () => {
  const initial: FormState = { step: 0, fields: { name: "", email: "" }, errors: {} };
 
  it("sets a field value", () => {
    const next = formReducer(initial, { type: "SET_FIELD", name: "name", value: "Alice" });
    expect(next.fields.name).toBe("Alice");
  });
 
  it("advances to the next step", () => {
    const next = formReducer(initial, { type: "NEXT_STEP" });
    expect(next.step).toBe(1);
    expect(next.errors).toEqual({});
  });
 
  it("clears field error on SET_FIELD", () => {
    const withError = { ...initial, errors: { name: "Required" } };
    const next = formReducer(withError, { type: "SET_FIELD", name: "name", value: "Alice" });
    expect(next.errors.name).toBe("");
  });
 
  it("does not go below step 0", () => {
    const next = formReducer(initial, { type: "PREV_STEP" });
    expect(next.step).toBe(0);
  });
 
  it("resets to initial state", () => {
    const modified = { step: 2, fields: { name: "Alice", email: "a@b.com" }, errors: {} };
    const next = formReducer(modified, { type: "RESET" });
    expect(next).toEqual(initial);
  });
});

Gotchas

  • Stale state in async callbacks — If you read state inside a setTimeout or await, you get the value at dispatch time, not the latest. Fix: Use useRef to mirror current state, or dispatch an action from the async callback instead of reading state.

  • Object spread creates shallow copies only — Nested objects (like fields inside state) must also be spread: { ...state, fields: { ...state.fields, [name]: value } }. Fix: Always spread at every nesting level you're modifying, or use Immer's produce.

  • Forgetting to clear errors on field change — Users fix the error but the message stays. Fix: Clear the specific field's error inside SET_FIELD, as shown in the working example.

  • Reducer must be pure — No API calls, no localStorage reads, no Date.now() inside the reducer. Fix: Perform side effects in the component or event handler, then dispatch the result.

  • Validation runs against stale state after dispatchdispatch doesn't update state synchronously. Calling dispatch({ type: "SET_FIELD" }) then reading state.fields gives the old value. Fix: Validate using the value you're about to dispatch, not the current state.

  • Large reducers become hard to read — A 200-line switch statement is worse than 8 useState calls. Fix: Extract case handlers into named functions: case "SET_FIELD": return handleSetField(state, action);.

  • Missing default case in switch — TypeScript won't warn if you miss a case unless you add exhaustive checking. Fix: Add default: { const _exhaustive: never = action; return state; } to catch unhandled actions at compile time.

Alternatives

AlternativeUse WhenDon't Use When
Multiple useStateForm has 1-3 fields, no multi-stepState transitions are coordinated across fields
React Hook FormYou need built-in validation, focus management, and field registrationYou want full control over state shape and transitions
Zustand / JotaiForm state must persist across routes or be shared globallyState is local to one component tree
XStateForm has complex branching logic (skip steps, loops, parallel states)A linear wizard with simple validation
useActionStateServer Action form with pending/error stateMulti-step client-side wizard with no server round-trip per step

FAQs

When should I use useReducer instead of useState for forms?
  • When the form has 4+ fields with interdependent state (e.g., changing one field affects validation of another)
  • When you have multi-step navigation where step, fields, and errors must stay in sync
  • When you want to unit test state transitions without mounting a React component
  • When multiple event handlers need to update the same state in different ways
  • Rule of thumb: if you find yourself calling 3+ setState functions in one handler, switch to useReducer
Can I use useReducer with Server Actions?
  • Yes — manage client-side wizard state with useReducer, then call the Server Action on final submission
  • Wrap the Server Action call in startTransition for pending state via useTransition
  • Dispatch a VALIDATE action with server-returned errors to display them in the current step
  • useActionState handles single-step server forms better; useReducer is for multi-step client wizards
How do I persist form state across page navigations?
  • Use the lazy initializer (third argument to useReducer) to load from localStorage
  • Save state to localStorage in a useEffect that runs on every state change
  • Alternatively, lift the reducer to a context provider that wraps the route group
  • For URL-persisted state, serialize step and key fields into search params
How do I validate only the current step?
  • Write a validateStep(stepIndex, fields) function that checks only the fields relevant to that step
  • Call it in handleNext before dispatching NEXT_STEP
  • If errors are found, dispatch VALIDATE with the errors and return early
  • This prevents users from being blocked by errors on steps they haven't reached yet
Should the reducer handle async operations like API calls?
  • No — reducers must be pure functions with no side effects
  • Perform async work in the event handler or useEffect, then dispatch the result
  • Example: const result = await saveStep(fields); dispatch({ type: "SAVE_SUCCESS", data: result });
  • This keeps the reducer testable and predictable
How do I handle conditional/branching steps?
  • Add branching logic inside the NEXT_STEP case of the reducer
  • Check field values to determine which step index to jump to
  • Example: skip the payment step if plan === "free" by incrementing step by 2
  • Keep a steps array or map so the progress bar reflects the actual path
How do I get TypeScript to catch missing action cases?
  • Use a discriminated union for your action type
  • Add exhaustive checking in the default case: const _exhaustive: never = action; return state;
  • TypeScript will error at compile time if you add a new action type but forget to handle it
  • This is safer than a bare default: return state which silently swallows unknown actions
Why does dispatch not update state immediately?
  • dispatch schedules a re-render — state reflects the new value on the next render, not synchronously
  • If you need to act on the "new" value, compute it yourself: const nextStep = step + 1; dispatch(...) then use nextStep
  • Alternatively, use a useEffect that reacts to state changes
  • This is the same batching behavior as useState — not unique to useReducer
How do I avoid a massive switch statement in the reducer?
  • Extract each case into a named handler function: case "SET_FIELD": return handleSetField(state, action);
  • Group related actions into sub-reducers if the form has distinct sections
  • Consider a handler map: const handlers: Record<string, Handler> = { SET_FIELD: handleSetField, ... }
  • If the reducer exceeds ~50 lines, it's a sign to break it up
Can I use Immer with useReducer for simpler updates?
  • Yes — wrap your reducer with produce from Immer: const reducer = produce((draft, action) => { draft.fields.name = action.value; })
  • This lets you write "mutating" code that produces immutable updates under the hood
  • Especially useful when state is deeply nested (e.g., state.steps[2].fields.address.city)
  • Adds ~5KB but eliminates spread-at-every-level boilerplate
How does this compare to XState for wizard forms?
  • useReducer is simpler: just a switch statement, no library, no learning curve
  • XState is better when steps can branch, loop, or run in parallel (true state machine behavior)
  • useReducer handles linear and simple-branching wizards well
  • If you find yourself adding canGoToStep3 booleans, consider upgrading to XState
What's the TypeScript type for the dispatch function?
  • React.Dispatch<FormAction> — where FormAction is your discriminated union
  • Pass it through context: createContext<Dispatch<FormAction> | null>(null)
  • Create a typed hook: function useFormDispatch() { const d = useContext(Ctx); if (!d) throw ...; return d; }
  • This avoids prop-drilling dispatch through many component levels