Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
// Simple state machine with useReducer
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: unknown }
| { status: "error"; error: Error };
type FetchEvent =
| { type: "FETCH" }
| { type: "RESOLVE"; data: unknown }
| { type: "REJECT"; error: Error }
| { type: "RESET" };
function fetchReducer(state: FetchState, event: FetchEvent): FetchState {
switch (state.status) {
case "idle":
if (event.type === "FETCH") return { status: "loading" };
return state;
case "loading":
if (event.type === "RESOLVE") return { status: "success", data: event.data };
if (event.type === "REJECT") return { status: "error", error: event.error };
return state;
case "error":
if (event.type === "FETCH") return { status: "loading" };
if (event.type === "RESET") return { status: "idle" };
return state;
case "success":
if (event.type === "FETCH") return { status: "loading" };
if (event.type === "RESET") return { status: "idle" };
return state;
}
}
const [state, send] = useReducer(fetchReducer, { status: "idle" });When to reach for this: When a component has multiple states with constrained transitions (e.g., a multi-step form, async workflow, or modal with loading/error/success). If you find yourself juggling multiple booleans like isLoading, isError, isSuccess, use a state machine instead.
import { useReducer, useCallback, type ReactNode } from "react";
// --- Multi-step form state machine ---
interface FormData {
name: string;
email: string;
plan: string;
}
type FormState =
| { step: "details"; data: Partial<FormData> }
| { step: "plan"; data: Partial<FormData> }
| { step: "review"; data: FormData }
| { step: "submitting"; data: FormData }
| { step: "complete"; data: FormData }
| { step: "error"; data: FormData; error: string };
type FormEvent =
| { type: "NEXT"; fields: Partial<FormData> }
| { type: "BACK" }
| { type: "SUBMIT" }
| { type: "SUCCESS" }
| { type: "FAIL"; error: string }
| { type: "RETRY" };
function formReducer(state: FormState, event: FormEvent): FormState {
switch (state.step) {
case "details":
if (event.type === "NEXT") {
return { step: "plan", data: { ...state.data, ...event.fields } };
}
return state;
case "plan":
if (event.type === "NEXT") {
const data = { ...state.data, ...event.fields } as FormData;
return { step: "review", data };
}
if (event.type === "BACK") return { step: "details", data: state.data };
return state;
case "review":
if (event.type === "SUBMIT") return { step: "submitting", data: state.data };
if (event.type === "BACK") return { step: "plan", data: state.data };
return state;
case "submitting":
if (event.type === "SUCCESS") return { step: "complete", data: state.data };
if (event.type === "FAIL") {
return { step: "error", data: state.data, error: event.error };
}
return state;
case "error":
if (event.type === "RETRY") return { step: "submitting", data: state.data };
if (event.type === "BACK") return { step: "review", data: state.data };
return state;
case "complete":
return state; // Terminal state
}
}
function SignupWizard() {
const [state, send] = useReducer(formReducer, {
step: "details",
data: {},
});
const handleSubmit = useCallback(async () => {
send({ type: "SUBMIT" });
try {
await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(state.step === "review" ? state.data : null),
});
send({ type: "SUCCESS" });
} catch (err) {
send({ type: "FAIL", error: (err as Error).message });
}
}, [state]);
switch (state.step) {
case "details":
return (
<DetailsStep
data={state.data}
onNext={(fields) => send({ type: "NEXT", fields })}
/>
);
case "plan":
return (
<PlanStep
data={state.data}
onNext={(fields) => send({ type: "NEXT", fields })}
onBack={() => send({ type: "BACK" })}
/>
);
case "review":
return (
<ReviewStep
data={state.data}
onSubmit={handleSubmit}
onBack={() => send({ type: "BACK" })}
/>
);
case "submitting":
return <LoadingSpinner message="Creating your account..." />;
case "error":
return (
<ErrorDisplay
error={state.error}
onRetry={() => send({ type: "RETRY" })}
onBack={() => send({ type: "BACK" })}
/>
);
case "complete":
return <SuccessMessage data={state.data} />;
}
}What this demonstrates:
isLoading && isError confusionuseReducer is the built-in React primitive for state machines. The reducer function IS the state machine.switch on state.step (or state.status) acts as the state chart; nested if checks on event.type define valid transitions.| Concept | Implementation | Purpose |
|---|---|---|
| State | Discriminated union type | Represents all possible states with associated data |
| Event | Union of { type: string; ... } | All possible inputs that trigger transitions |
| Reducer | (state, event) => state | Pure function defining the state machine logic |
| Dispatch | send(event) | Trigger a state transition |
Guard conditions - allow transitions only when conditions are met:
case "details":
if (event.type === "NEXT") {
if (!event.fields.name || !event.fields.email) {
return { ...state, validationError: "All fields required" };
}
return { step: "plan", data: { ...state.data, ...event.fields } };
}
return state;Using XState for complex machines - when state logic exceeds what useReducer handles cleanly:
import { useMachine } from "@xstate/react";
import { createMachine, assign } from "xstate";
const toggleMachine = createMachine({
id: "toggle",
initial: "inactive",
context: { count: 0 },
states: {
inactive: {
on: {
TOGGLE: {
target: "active",
actions: assign({ count: ({ context }) => context.count + 1 }),
},
},
},
active: {
on: { TOGGLE: "inactive" },
},
},
});
function Toggle() {
const [state, send] = useMachine(toggleMachine);
return (
<button onClick={() => send({ type: "TOGGLE" })}>
{state.value} (toggled {state.context.count} times)
</button>
);
}step, status) enables type narrowing.switch exhaustive. TypeScript will warn if you miss a state when the return type is specified.boolean fields on state objects - they create 2^n possible states. Use explicit named states instead.typegen for inferred event types.Boolean soup - Using isLoading, isError, hasData as separate booleans creates impossible combinations like isLoading && isError. Fix: Replace with a single discriminated union state.
Side effects in the reducer - Reducers must be pure functions. API calls or DOM mutations inside the reducer break React rules. Fix: Trigger side effects outside the reducer based on state transitions (in event handlers or effects).
Forgetting the default return - If the reducer doesn't handle an event in a given state and doesn't return state, the state becomes undefined. Fix: Always add return state as the default for unhandled events in each state case.
Over-engineering simple state - A toggle that's either on or off doesn't need a state machine. Fix: Use useState(false) for trivial two-state scenarios. Reach for machines when you have 3+ states or complex transitions.
| Approach | Trade-off |
|---|---|
useReducer state machine | Built-in, no deps; manual transition logic |
| XState | Powerful, visual tooling; extra dependency, learning curve |
Multiple useState booleans | Simple for 1-2 states; impossible states become possible |
| Zustand with state field | Good for global state machines; not React-specific |
useActionState (React 19) | Designed for form submission flows; limited to form actions |
useReducer is the built-in React primitive for implementing state machines.type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: unknown }
| { status: "error"; error: Error };data only exists on success).status), preventing access to invalid fields.isLoading && isError become unrepresentable.isLoading, isError, isSuccess creates impossible combinations.useState(false) is sufficient.switch on state.step (or state.status) defines which state you are in.if checks on event.type define which transitions are valid in that state.return state ignores events that are not valid for the current state.useEffect based on state transitions.undefined, which can crash the component or cause silent bugs.return state as the default case for each state in the switch statement.case "details":
if (event.type === "NEXT") {
if (!event.fields.name || !event.fields.email) {
return { ...state, validationError: "All fields required" };
}
return { step: "plan", data: { ...state.data, ...event.fields } };
}
return state;never check in the default case to catch unhandled states at compile time.useReducer is sufficient and has zero dependencies.n boolean fields create 2^n possible combinations, most of which are invalid.{ isLoading: true, isError: true } is an impossible state that booleans allow."idle" | "loading" | "error" | "success".useEffect that watches the state.SUCCESS or FAIL based on the API response.
React SME Cookbook screenshotAuthor: Chris St. JohnSource: UnsplashLicense: Unsplash LicensePhoto: Chris St. John (Unsplash)
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥