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_FIELDclears the error for that specific field, giving inline feedback- The reducer is a pure function with no side effects, making it trivially unit-testable
RESETaction returns toinitialState— no risk of stale partial state
Deep Dive
How It Works
useReduceraccepts a pure reducer function and an initial state, returning the current state and adispatchfunction- Every state change is an explicit action with a
typestring — you can log, replay, or undo actions - The reducer centralizes all state transitions in one place, eliminating scattered
setStatecalls 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
| Concern | useState (multiple) | useReducer |
|---|---|---|
| State transitions | Spread across handlers | Centralized in reducer |
| Testing | Must mount component | Test reducer as pure function |
| Debugging | Check each setter | Log dispatched actions |
| Coordinated updates | Risk of partial updates | Single atomic dispatch |
| Adding new fields | Another useState call | Add 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
stateinside asetTimeoutorawait, you get the value at dispatch time, not the latest. Fix: UseuseRefto mirror current state, or dispatch an action from the async callback instead of reading state. -
Object spread creates shallow copies only — Nested objects (like
fieldsinsidestate) must also be spread:{ ...state, fields: { ...state.fields, [name]: value } }. Fix: Always spread at every nesting level you're modifying, or use Immer'sproduce. -
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
localStoragereads, noDate.now()inside the reducer. Fix: Perform side effects in the component or event handler, then dispatch the result. -
Validation runs against stale state after dispatch —
dispatchdoesn't updatestatesynchronously. Callingdispatch({ type: "SET_FIELD" })then readingstate.fieldsgives the old value. Fix: Validate using the value you're about to dispatch, not the currentstate. -
Large reducers become hard to read — A 200-line switch statement is worse than 8
useStatecalls. Fix: Extract case handlers into named functions:case "SET_FIELD": return handleSetField(state, action);. -
Missing
defaultcase in switch — TypeScript won't warn if you miss a case unless you add exhaustive checking. Fix: Adddefault: { const _exhaustive: never = action; return state; }to catch unhandled actions at compile time.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Multiple useState | Form has 1-3 fields, no multi-step | State transitions are coordinated across fields |
| React Hook Form | You need built-in validation, focus management, and field registration | You want full control over state shape and transitions |
| Zustand / Jotai | Form state must persist across routes or be shared globally | State is local to one component tree |
| XState | Form has complex branching logic (skip steps, loops, parallel states) | A linear wizard with simple validation |
useActionState | Server Action form with pending/error state | Multi-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+
setStatefunctions in one handler, switch touseReducer
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
startTransitionfor pending state viauseTransition - Dispatch a
VALIDATEaction with server-returned errors to display them in the current step useActionStatehandles single-step server forms better;useReduceris for multi-step client wizards
How do I persist form state across page navigations?
- Use the lazy initializer (third argument to
useReducer) to load fromlocalStorage - Save state to
localStoragein auseEffectthat 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
handleNextbefore dispatchingNEXT_STEP - If errors are found, dispatch
VALIDATEwith 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_STEPcase 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
stepsarray 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
defaultcase: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 statewhich silently swallows unknown actions
Why does dispatch not update state immediately?
dispatchschedules a re-render —statereflects 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 usenextStep - Alternatively, use a
useEffectthat reacts to state changes - This is the same batching behavior as
useState— not unique touseReducer
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
producefrom 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?
useReduceris 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)
useReducerhandles linear and simple-branching wizards well- If you find yourself adding
canGoToStep3booleans, consider upgrading to XState
What's the TypeScript type for the dispatch function?
React.Dispatch<FormAction>— whereFormActionis 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
dispatchthrough many component levels
Related
- Form Patterns Basic — Controlled inputs, single-step forms with useState
- Form Patterns Complex — Multi-step wizards using React Hook Form
- Server Action Forms — Progressive enhancement with useActionState
- Form Error Display — Patterns for showing validation errors
- React Hook Form — Library-based form management as an alternative