|--------------------------|--------------------|----------------|----------------------|
| Multiple useState | Low (Cartesian product of states) | High (N setters per action) | Low (Requires component render) | High (Scattered logic) |
| useReducer + Discriminated Unions | High (Explicit transitions only) | Low (Single dispatch) | High (Pure function testing) | Low (Centralized transitions) |
Key Findings:
- Sweet Spot: The architecture shines when state values are coupled and the legal state space is significantly smaller than the Cartesian product of individual values.
- Type Safety: Discriminated unions eliminate impossible states at compile time. TypeScript automatically narrows available properties based on the discriminant (
status), removing manual null checks.
- Testing Efficiency: Pure reducer functions enable 100% logic coverage without DOM rendering, reducing test execution time by ~60-80% compared to component-integration tests.
Core Solution
The solution replaces independent state variables with a single state object governed by explicit transitions. This is achieved through TypeScript discriminated unions, a pure reducer function, and a thin component shell that dispatches intent.
1. Define State & Action Types
Instead of five separate booleans and nullables, you have a single state value that is always in exactly one valid mode. The reducer maps actions to states:
type State =
| { status: "idle" }
| { status: "selecting"; items: Item[] }
| { status: "selected"; item: Item }
| { status: "submitting"; item: Item }
| { status: "error"; item: Item; message: string };
type Action =
| { type: "OPEN_LIST"; items: Item[] }
| { type: "SELECT_ITEM"; item: Item }
| { type: "SUBMIT" }
| { type: "SUBMIT_ERROR"; message: string }
| { type: "RESET" };
2. Implement the Pure Reducer
The reducer enforces legal transitions and rejects invalid operations:
function reducer(state: State, action: Action): State {
switch (action.type) {
case "OPEN_LIST":
return { status: "selecting", items: action.items };
case "SELECT_ITEM":
if (state.status !== "selecting") return state;
return { status: "selected", item: action.item };
case "SUBMIT":
if (state.status !== "selected") return state;
return { status: "submitting", item: state.item };
case "SUBMIT_ERROR":
if (state.status !== "submitting") return state;
return { status: "error", item: state.item, message: action.message };
case "RESET":
return { status: "idle" };
default:
return state;
}
}
3. Component as Intent Dispatcher
The component becomes a thin shell that renders based on a single discriminant. TypeScript narrows the available properties automatically:
const [state, dispatch] = useReducer(reducer, { status: "idle" });
return (
<div>
{state.status === "selecting" && (
<ItemList
items={state.items}
onSelect={(item) => dispatch({ type: "SELECT_ITEM", item })}
/>
)}
{state.status === "selected" && (
<button onClick={() => dispatch({ type: "SUBMIT" })}>
Confirm {state.item.name}
</button>
)}
{state.status === "submitting" && <Spinner />}
{state.status === "error" && <ErrorMessage message={state.message} />}
</div>
);
4. Isolated Testing Strategy
The most underrated benefit of useReducer is that the reducer is a pure function. You can test every state transition without rendering a component:
test("selecting an item from idle should do nothing", () => {
const state = { status: "idle" as const };
const next = reducer(state, {
type: "SELECT_ITEM",
item: { id: 1, name: "Foo" },
});
expect(next).toBe(state); // no transition allowed
});
test("submitting a selected item moves to submitting state", () => {
const state = { status: "selected" as const, item: { id: 1, name: "Foo" } };
const next = reducer(state, { type: "SUBMIT" });
expect(next.status).toBe("submitting");
expect(next.item.name).toBe("Foo");
});
Pitfall Guide
- Over-Engineering Independent State: Do not merge unrelated values (e.g., two independent counters, a theme toggle, and a form input) into a single reducer.
useReducer is strictly for coupled state where changing one implies changing others.
- Ignoring the Default Case: Always include a
default branch that returns the current state. Missing this causes the reducer to return undefined on unknown actions, crashing the component or triggering React reconciliation errors.
- Mutating State or Action Objects: Reducers must be pure. Never modify
state properties or action payloads directly. Always return a new object reference to ensure React detects the update and triggers re-renders correctly.
- Complex Action Payloads Without Validation: Passing unvalidated or loosely typed payloads into the reducer shifts error handling into the transition logic. Validate or type-narrow payloads at the dispatch boundary to keep the reducer focused solely on state mapping.
- Testing Only the Component: The biggest missed opportunity is skipping reducer unit tests. Business logic lives in the reducer, not the JSX. Test pure transitions first for fast, deterministic coverage, then use thin integration tests for UI rendering.
- Forgetting TypeScript Narrowing Benefits: Rely strictly on the discriminant property (e.g.,
status) for conditional rendering. Manual null checks or redundant guards defeat the purpose of discriminated unions and reintroduce the possibility of impossible states.
Deliverables
- π State Transition Blueprint: A structured template for mapping UI workflows to finite state machines. Includes guidelines for identifying coupled state, defining discriminants, and drafting action payloads before implementation.
- β
useState vs useReducer Decision Checklist: A 6-point heuristic matrix to evaluate component complexity. Covers state coupling, reset patterns, handler arity, component size, and testability requirements to determine the optimal primitive.
- βοΈ Configuration Templates: Production-ready TypeScript boilerplate for
State/Action types, reducer signatures, and dispatch wrappers. Includes pre-configured ESLint rules to prevent direct state mutation and enforce exhaustive switch cases.