dispatch function.
Implementation Strategy
- Define State Shape: Create a structured object representing the component's data.
- Define Action Types: Establish a contract for state transitions using typed actions.
- Implement Reducer: Write a pure function that maps actions to new state.
- Initialize Hook: Connect the reducer to the component.
- Dispatch Actions: Replace setter calls with dispatch invocations.
Code Example: Order Processing System
The following example demonstrates a robust implementation using TypeScript for type safety. This scenario manages an order with interdependent fields (items, total, status), illustrating how useReducer maintains consistency.
import { useReducer, Dispatch } from 'react';
// 1. Define State Interface
interface OrderState {
items: Array<{ id: string; name: string; price: number }>;
total: number;
status: 'pending' | 'processing' | 'completed';
error: string | null;
}
// 2. Define Action Types
type OrderAction =
| { type: 'ADD_ITEM'; payload: { id: string; name: string; price: number } }
| { type: 'REMOVE_ITEM'; payload: string }
| { type: 'APPLY_DISCOUNT'; payload: number }
| { type: 'SUBMIT_ORDER' }
| { type: 'RESET_ORDER' };
// 3. Implement Reducer
function orderReducer(state: OrderState, action: OrderAction): OrderState {
switch (action.type) {
case 'ADD_ITEM': {
const newItem = action.payload;
const updatedItems = [...state.items, newItem];
return {
...state,
items: updatedItems,
total: state.total + newItem.price,
error: null,
};
}
case 'REMOVE_ITEM': {
const itemId = action.payload;
const itemToRemove = state.items.find((item) => item.id === itemId);
if (!itemToRemove) return state;
const updatedItems = state.items.filter((item) => item.id !== itemId);
return {
...state,
items: updatedItems,
total: state.total - itemToRemove.price,
};
}
case 'APPLY_DISCOUNT': {
const discount = action.payload;
if (discount < 0 || discount > 100) {
return { ...state, error: 'Invalid discount percentage' };
}
const discountedTotal = state.total * (1 - discount / 100);
return {
...state,
total: discountedTotal,
error: null,
};
}
case 'SUBMIT_ORDER': {
if (state.items.length === 0) {
return { ...state, error: 'Cannot submit empty order' };
}
return { ...state, status: 'processing', error: null };
}
case 'RESET_ORDER':
return {
items: [],
total: 0,
status: 'pending',
error: null,
};
default:
return state;
}
}
// 4. Component Implementation
const initialState: OrderState = {
items: [],
total: 0,
status: 'pending',
error: null,
};
export function OrderDashboard() {
const [order, dispatch] = useReducer(orderReducer, initialState);
const handleAddItem = () => {
dispatch({
type: 'ADD_ITEM',
payload: { id: 'item-1', name: 'Widget A', price: 29.99 },
});
};
const handleSubmit = () => {
dispatch({ type: 'SUBMIT_ORDER' });
};
return (
<div>
<h2>Order Status: {order.status}</h2>
<p>Total: ${order.total.toFixed(2)}</p>
{order.error && <p className="error">{order.error}</p>}
<button onClick={handleAddItem}>Add Widget A</button>
<button onClick={handleSubmit} disabled={order.status !== 'pending'}>
Submit Order
</button>
</div>
);
}
Architecture Decisions
- Pure Reducer Function: The reducer contains no side effects. It computes the next state based solely on the current state and action. This ensures predictability and enables unit testing without rendering the component.
- Action Payloads: Actions carry only the data necessary for the transition. This decouples the component from the logic, allowing the reducer to handle calculations (e.g., updating
total when an item is added).
- Default Case: The reducer returns the current state for unknown actions, preventing crashes and ensuring stability.
- TypeScript Interfaces: Strict typing for
OrderState and OrderAction catches errors at compile time, ensuring actions match expected payloads and state shapes remain consistent.
Pitfall Guide
Even with a robust pattern, developers often encounter specific issues when implementing useReducer. The following pitfalls and fixes are derived from production experience.
-
Direct State Mutation
- Explanation: Modifying the state object directly (e.g.,
state.total += 10) breaks React's change detection. React relies on reference equality to trigger re-renders.
- Fix: Always return a new object. Use spread syntax (
{ ...state, total: state.total + 10 }) or immutable update libraries.
-
Side Effects in Reducer
- Explanation: Placing API calls, timers, or DOM manipulation inside the reducer causes unpredictable behavior. Reducers must be pure functions.
- Fix: Move side effects to event handlers or
useEffect hooks. The reducer should only update state based on inputs.
-
Magic String Action Types
- Explanation: Using raw strings for action types (e.g.,
'add_item') increases the risk of typos, which can silently fail or cause bugs.
- Fix: Define action types as constants or use TypeScript string literals. This enables autocomplete and compile-time validation.
-
Over-Engineering Simple State
- Explanation: Using
useReducer for a simple boolean toggle or single value adds unnecessary boilerplate and complexity.
- Fix: Reserve
useReducer for complex state with multiple update paths or interdependent fields. Use useState for simple, isolated values.
-
Payload Structure Inconsistency
- Explanation: Varying payload structures across actions can lead to runtime errors when the reducer expects specific data.
- Fix: Define strict interfaces for each action type. Ensure payloads are validated before dispatching.
-
Reducer Bloat
- Explanation: As logic grows, the reducer function can become unwieldy, reducing readability and maintainability.
- Fix: Extract complex calculations into helper functions. Consider splitting the reducer into smaller, focused reducers if the state becomes too large.
-
Neglecting Error Handling
- Explanation: Failing to handle invalid actions or payloads can leave the state in an undefined condition.
- Fix: Implement validation within the reducer and update an
error field in the state when invalid data is detected.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple Toggle | useState | Minimal boilerplate; direct control | Low |
| Form with Validation | useReducer | Coordinated updates; atomic transitions | Medium |
| Multi-step Wizard | useReducer | State machine pattern; clear transitions | High |
| Interdependent Fields | useReducer | Prevents data drift; ensures consistency | Medium |
| Isolated Value | useState | Simpler implementation; less overhead | Low |
Configuration Template
The following template provides a production-ready setup for useReducer with TypeScript.
import { useReducer, Reducer } from 'react';
// State Interface
interface MyState {
// Define state properties
value: number;
status: 'idle' | 'loading' | 'success' | 'error';
}
// Action Types
type MyAction =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET_VALUE'; payload: number }
| { type: 'RESET' };
// Reducer Function
const myReducer: Reducer<MyState, MyAction> = (state, action) => {
switch (action.type) {
case 'INCREMENT':
return { ...state, value: state.value + 1 };
case 'DECREMENT':
return { ...state, value: state.value - 1 };
case 'SET_VALUE':
return { ...state, value: action.payload };
case 'RESET':
return { value: 0, status: 'idle' };
default:
return state;
}
};
// Initial State
const initialState: MyState = {
value: 0,
status: 'idle',
};
// Custom Hook
export function useMyState() {
const [state, dispatch] = useReducer(myReducer, initialState);
return { state, dispatch };
}
Quick Start Guide
- Define State Shape: Create an interface for your state object.
- Write Reducer: Implement a pure function handling all state transitions.
- Initialize Hook: Call
useReducer(reducer, initialState) in your component.
- Dispatch Actions: Use
dispatch to trigger state changes via defined actions.
- Test Reducer: Verify reducer logic with unit tests for each action type.