s disciplined separation of concerns. The following steps outline a production-ready implementation using TypeScript, strict immutability, and explicit action typing.
Step 1: Define the State Shape and Action Contract
Start by modeling the data structure and the events that can modify it. Use discriminated unions for actions to ensure type safety and exhaustive checking.
interface WorkflowState {
currentStep: number;
totalSteps: number;
draftData: Record<string, string>;
validationErrors: Record<string, string>;
submissionStatus: 'idle' | 'submitting' | 'success' | 'failed';
}
type WorkflowAction =
| { type: 'SET_STEP'; payload: number }
| { type: 'UPDATE_FIELD'; field: string; value: string }
| { type: 'CLEAR_ERRORS' }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_SUCCESS' }
| { type: 'SUBMIT_FAILURE'; error: string };
Step 2: Implement the Pure Reducer Function
The reducer must be a pure function. It receives the current state and an action, then returns a new state object. No side effects, no external dependencies, no direct mutations.
function workflowReducer(
state: WorkflowState,
action: WorkflowAction
): WorkflowState {
switch (action.type) {
case 'SET_STEP':
return { ...state, currentStep: action.payload };
case 'UPDATE_FIELD':
return {
...state,
draftData: { ...state.draftData, [action.field]: action.value },
validationErrors: { ...state.validationErrors, [action.field]: '' }
};
case 'CLEAR_ERRORS':
return { ...state, validationErrors: {} };
case 'SUBMIT_START':
return { ...state, submissionStatus: 'submitting' };
case 'SUBMIT_SUCCESS':
return { ...state, submissionStatus: 'success', currentStep: state.totalSteps };
case 'SUBMIT_FAILURE':
return {
...state,
submissionStatus: 'failed',
validationErrors: { general: action.error }
};
default:
return state;
}
}
Step 3: Initialize the Hook in the Component
Pass the reducer and initial state to useReducer. The hook returns the current state snapshot and a dispatch function.
const initialWorkflowState: WorkflowState = {
currentStep: 1,
totalSteps: 3,
draftData: {},
validationErrors: {},
submissionStatus: 'idle'
};
function CheckoutWizard() {
const [workflow, dispatch] = React.useReducer(
workflowReducer,
initialWorkflowState
);
// ...
}
Step 4: Wire Dispatch to UI Events
Actions describe intent. They never mutate state directly. The component only calls dispatch with an action object.
function handleFieldChange(field: string, value: string) {
dispatch({ type: 'UPDATE_FIELD', field, value });
}
function handleNextStep() {
const nextStep = workflow.currentStep + 1;
if (nextStep <= workflow.totalSteps) {
dispatch({ type: 'SET_STEP', payload: nextStep });
}
}
function handleSubmit() {
dispatch({ type: 'SUBMIT_START' });
// Async logic handled outside reducer
api.saveDraft(workflow.draftData)
.then(() => dispatch({ type: 'SUBMIT_SUCCESS' }))
.catch((err) => dispatch({ type: 'SUBMIT_FAILURE', error: err.message }));
}
Architecture Decisions and Rationale
Why separate action types from state updates?
Decoupling intent from execution allows you to log, test, and replay state transitions independently of the UI. It also enables middleware patterns (e.g., logging, analytics, validation guards) without touching component code.
Why enforce immutability?
React's reconciliation algorithm relies on reference equality to detect changes. Returning a new object ensures the framework recognizes the update. Spreading the previous state (...state) preserves unchanged slices while replacing only the affected properties.
Why keep reducers pure?
Pure functions guarantee deterministic output for identical input. This enables unit testing without mocking DOM events or network layers. It also prevents hidden side effects that cause race conditions in concurrent rendering modes.
Why handle async outside the reducer?
Reducers must remain synchronous and pure. Network calls, timers, or DOM interactions belong in event handlers or custom hooks. Dispatch before the request to set loading state, then dispatch success/failure actions in the promise resolution.
Pitfall Guide
1. Direct State Mutation
Explanation: Modifying state.draftData[field] = value inside the reducer breaks reference tracking. React will not re-render because the object reference remains unchanged.
Fix: Always return a new object. Use spread syntax or structuredClone for deeply nested updates. Consider immer if mutation syntax is preferred, but wrap it carefully to maintain reducer purity.
2. Over-Engineering Simple State
Explanation: Wrapping a single boolean toggle or isolated counter in a reducer adds unnecessary boilerplate without gaining architectural benefits.
Fix: Reserve useReducer for state where updates are interdependent, follow a lifecycle, or require centralized validation. Use useState for independent, isolated values.
3. Async Logic Inside Reducers
Explanation: Attempting to await API calls or set timers inside a reducer violates purity and breaks React's rendering cycle. The reducer must return synchronously.
Fix: Dispatch a START action, perform the async operation in the event handler or custom hook, then dispatch SUCCESS or FAILURE actions upon resolution.
4. Action Type Collisions
Explanation: Using string literals like 'UPDATE' or 'SET' across multiple reducers causes dispatch routing errors when reducers are combined or lifted to context.
Fix: Prefix action types with a domain identifier (e.g., 'WORKFLOW/SET_STEP') or use a constants file. TypeScript discriminated unions catch mismatches at compile time.
5. Missing Default Case
Explanation: Omitting the default branch in a switch statement causes the reducer to return undefined for unrecognized actions, crashing the component on next render.
Fix: Always include default: return state;. In TypeScript, use never type checking to ensure all action variants are handled.
6. Ignoring TypeScript Exhaustiveness
Explanation: Adding a new action type without updating the reducer leaves unhandled branches, leading to silent failures or stale state.
Fix: Use a type guard or assertNever helper in the default case:
default: {
const _exhaustiveCheck: never = action;
throw new Error(`Unhandled action: ${_exhaustiveCheck}`);
}
7. Dispatching During Render
Explanation: Calling dispatch directly in the component body (outside effects or event handlers) triggers infinite render loops because state updates cause re-renders, which trigger dispatches again.
Fix: Restrict dispatch calls to event handlers, useEffect cleanup/setup, or custom hooks. Use useRef to guard against duplicate dispatches if necessary.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single boolean toggle or isolated counter | useState | Minimal boilerplate, direct setter is clearer | Low |
| Form with validation, loading, and step tracking | useReducer | Centralized transition logic prevents race conditions | Medium |
| Shared state across distant components | useReducer + Context | Avoids prop drilling while maintaining predictable updates | Medium-High (requires memoization) |
| Complex global state with middleware needs | Redux Toolkit or Zustand | Built-in devtools, persistence, and async handling | High (framework overhead) |
| State dependent on previous values + multiple triggers | useReducer | Reducer guarantees consistent state transitions regardless of trigger source | Low |
Configuration Template
// types.ts
export interface AppState {
items: string[];
filter: string;
status: 'idle' | 'loading' | 'error';
error: string | null;
}
export type AppAction =
| { type: 'SET_FILTER'; payload: string }
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: string[] }
| { type: 'FETCH_ERROR'; payload: string };
// reducer.ts
export const initialState: AppState = {
items: [],
filter: '',
status: 'idle',
error: null
};
export function appReducer(state: AppState, action: AppAction): AppState {
switch (action.type) {
case 'SET_FILTER':
return { ...state, filter: action.payload };
case 'FETCH_START':
return { ...state, status: 'loading', error: null };
case 'FETCH_SUCCESS':
return { ...state, status: 'idle', items: action.payload };
case 'FETCH_ERROR':
return { ...state, status: 'error', error: action.payload };
default:
const _check: never = action;
throw new Error(`Unknown action: ${_check}`);
}
}
// component.tsx
import { useReducer, useCallback } from 'react';
import { appReducer, initialState, AppAction } from './reducer';
export function DataViewer() {
const [state, dispatch] = useReducer(appReducer, initialState);
const handleFilterChange = useCallback((value: string) => {
dispatch({ type: 'SET_FILTER', payload: value });
}, []);
const loadData = useCallback(async () => {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch(`/api/items?filter=${state.filter}`);
const data = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (err) {
dispatch({ type: 'FETCH_ERROR', payload: err instanceof Error ? err.message : 'Unknown error' });
}
}, [state.filter]);
return (
<div>
<input value={state.filter} onChange={(e) => handleFilterChange(e.target.value)} />
<button onClick={loadData} disabled={state.status === 'loading'}>
{state.status === 'loading' ? 'Loading...' : 'Refresh'}
</button>
{state.error && <p className="error">{state.error}</p>}
<ul>
{state.items.map((item, i) => <li key={i}>{item}</li>)}
</ul>
</div>
);
}
Quick Start Guide
- Define your state and actions: Create TypeScript interfaces for your state shape and a discriminated union for all possible actions. This establishes the contract before writing logic.
- Write the reducer: Implement a pure function that switches on
action.type. Return new state objects using spread syntax. Add a default case with never type assertion.
- Initialize the hook: Call
useReducer(reducer, initialState) at the top of your component. Destructure state and dispatch.
- Wire events to dispatch: Replace direct state setters with
dispatch({ type: 'ACTION_NAME', payload: value }). Handle async operations outside the reducer, dispatching lifecycle actions before and after the operation.
- Verify in development: Open React DevTools or Redux DevTools extension. Confirm that every state change corresponds to a logged action. Test edge cases by dispatching actions manually to ensure the reducer handles them gracefully.