Introduction
As you build more complex features with useReducer, you will encounter common patterns for structuring reducers, handling derived state, managing nested objects, and modeling state machines. This lesson covers the patterns that make reducers scalable and maintainable in real applications.
Key Concepts
- Action Creators: Functions that create action objects, centralizing the logic for building actions with payloads.
- Immer Integration: Using Immer to write "mutative" reducer code that produces immutable results behind the scenes.
- State Machines: Modeling state as a finite set of states with defined transitions, preventing impossible states.
- Derived State: Computing values from reducer state rather than storing redundant data.
Real World Context
A file upload feature has states like idle, selecting, uploading (with progress), success, and error. Each state allows only certain transitions: you cannot go from idle to uploading without first selecting a file. Modeling this as a state machine in a reducer makes impossible states unrepresentable and eliminates entire classes of bugs.
Deep Dive
Action Creators
Instead of building action objects inline, create helper functions:
tsx// Action creators — centralize action construction const actions = { addItem: (product: Product) => ({ type: 'ADD_ITEM' as const, payload: product, }), removeItem: (id: string) => ({ type: 'REMOVE_ITEM' as const, payload: id, }), updateQuantity: (id: string, qty: number) => ({ type: 'UPDATE_QUANTITY' as const, payload: { id, qty }, }), clearCart: () => ({ type: 'CLEAR_CART' as const }), }; // Usage is more readable dispatch(actions.addItem(product)); dispatch(actions.updateQuantity('abc', 3));
State Machine Pattern
Model valid states and transitions explicitly:
tsxtype UploadState = | { status: 'idle' } | { status: 'selected'; file: File } | { status: 'uploading'; file: File; progress: number } | { status: 'success'; url: string } | { status: 'error'; error: string }; type UploadAction = | { type: 'SELECT_FILE'; payload: File } | { type: 'START_UPLOAD' } | { type: 'PROGRESS'; payload: number } | { type: 'UPLOAD_SUCCESS'; payload: string } | { type: 'UPLOAD_ERROR'; payload: string } | { type: 'RESET' }; function uploadReducer(state: UploadState, action: UploadAction): UploadState { switch (action.type) { case 'SELECT_FILE': return { status: 'selected', file: action.payload }; case 'START_UPLOAD': if (state.status !== 'selected') return state; // Guard: can only start from selected return { status: 'uploading', file: state.file, progress: 0 }; case 'PROGRESS': if (state.status !== 'uploading') return state; return { ...state, progress: action.payload }; case 'UPLOAD_SUCCESS': return { status: 'success', url: action.payload }; case 'UPLOAD_ERROR': return { status: 'error', error: action.payload }; case 'RESET': return { status: 'idle' }; default: return state; } }
Derived State from Reducer
Never store values that can be computed from existing state:
tsxtype CartState = { items: Array<{ id: string; name: string; price: number; quantity: number }>; // Do NOT store: total, itemCount — derive them }; function CartSummary() { const [cart, dispatch] = useReducer(cartReducer, { items: [] }); // Derived values computed during render const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0); const total = cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0); return <p>{itemCount} items - Total: ${total.toFixed(2)}</p>; }
Lazy Initialization
For expensive initial state computation, pass an init function:
tsxfunction init(savedData: string | null): CartState { if (savedData) { try { return JSON.parse(savedData); } catch { /* fall through */ } } return { items: [] }; } const [cart, dispatch] = useReducer( cartReducer, localStorage.getItem('cart'), // raw initialArg init // init function transforms it );
Common Pitfalls
- Storing derived state in the reducer — Storing
totalalongsideitemsmeans you must update both on every item change. Computetotalfromitemsduring render instead. - Missing guards for invalid transitions — A reducer should handle (or ignore) actions that are not valid in the current state. Without guards, you can reach impossible states.
Best Practices
- Use discriminated union types — TypeScript's discriminated unions for both state and actions give you exhaustive checking and prevent impossible state combinations.
- Keep the reducer pure and move side effects outside — The reducer computes the next state; the component triggers side effects (API calls, navigation) based on the new state.
Summary
- Action creators make dispatch calls more readable and centralize action construction.
- State machines model valid states and transitions explicitly, preventing impossible states.
- Derive values from state during render instead of storing redundant data in the reducer.
Code Examples
type FormState =
| { status: 'editing'; values: Record<string, string>; errors: Record<string, string> }
| { status: 'submitting'; values: Record<string, string> }
| { status: 'submitted' }
| { status: 'error'; values: Record<string, string>; serverError: string };
type FormAction =
| { type: 'UPDATE_FIELD'; field: string; value: string }
| { type: 'SUBMIT' }
| { type: 'SUBMIT_SUCCESS' }
| { type: 'SUBMIT_ERROR'; error: string };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'UPDATE_FIELD':
if (state.status !== 'editing') return state;
return { ...state, values: { ...state.values, [action.field]: action.value } };
case 'SUBMIT':
if (state.status !== 'editing') return state;
return { status: 'submitting', values: state.values };
case 'SUBMIT_SUCCESS':
return { status: 'submitted' };
case 'SUBMIT_ERROR':
if (state.status !== 'submitting') return state;
return { status: 'error', values: state.values, serverError: action.error };
default:
return state;
}
}