Introduction
While useState works well for simple state (a counter, a form field, a boolean toggle), it becomes unwieldy when state has multiple sub-values with complex update logic. The useReducer hook provides an alternative that centralizes state transitions in a pure reducer function, making complex state predictable and testable.
Key Concepts
- Reducer Function: A pure function
(state, action) => newStatethat takes the current state and an action, and returns the new state. - Dispatch Function: The function returned by
useReducerthat sends actions to the reducer. - Action: An object (typically with a
typeproperty) that describes what happened. May include apayloadwith additional data. - Initial State: The starting state value passed as the second argument to
useReducer.
Real World Context
A multi-step checkout form has state for the current step, form data for shipping/billing/payment, validation errors, and a loading flag for the submission. Using useState for each piece means coordinating updates across 5+ setter functions. A reducer centralizes all these transitions: NEXT_STEP, PREV_STEP, UPDATE_FIELD, SET_ERROR, SUBMIT_START, SUBMIT_SUCCESS. Each action is a clear, declarative description of what happened.
Deep Dive
Basic Syntax
tsxconst [state, dispatch] = useReducer(reducer, initialState);
Example: Task Manager
tsximport { useReducer } from 'react'; type Task = { id: number; text: string; done: boolean }; type State = { tasks: Task[]; filter: 'all' | 'active' | 'completed' }; type Action = | { type: 'ADD_TASK'; payload: string } | { type: 'TOGGLE_TASK'; payload: number } | { type: 'DELETE_TASK'; payload: number } | { type: 'SET_FILTER'; payload: State['filter'] }; function taskReducer(state: State, action: Action): State { switch (action.type) { case 'ADD_TASK': return { ...state, tasks: [...state.tasks, { id: Date.now(), text: action.payload, done: false, }], }; case 'TOGGLE_TASK': return { ...state, tasks: state.tasks.map(task => task.id === action.payload ? { ...task, done: !task.done } : task ), }; case 'DELETE_TASK': return { ...state, tasks: state.tasks.filter(task => task.id !== action.payload), }; case 'SET_FILTER': return { ...state, filter: action.payload }; default: return state; } } function TaskManager() { const [state, dispatch] = useReducer(taskReducer, { tasks: [], filter: 'all', }); const [newTask, setNewTask] = useState(''); const handleAddTask = (e: React.FormEvent) => { e.preventDefault(); if (!newTask.trim()) return; dispatch({ type: 'ADD_TASK', payload: newTask.trim() }); setNewTask(''); }; const visibleTasks = state.tasks.filter(task => { if (state.filter === 'active') return !task.done; if (state.filter === 'completed') return task.done; return true; }); return ( <div> <form onSubmit={handleAddTask}> <input value={newTask} onChange={e => setNewTask(e.target.value)} /> <button type="submit">Add</button> </form> <div> {(['all', 'active', 'completed'] as const).map(f => ( <button key={f} onClick={() => dispatch({ type: 'SET_FILTER', payload: f })}> {f} </button> ))} </div> <ul> {visibleTasks.map(task => ( <li key={task.id}> <span onClick={() => dispatch({ type: 'TOGGLE_TASK', payload: task.id })} style={{ textDecoration: task.done ? 'line-through' : 'none' }} > {task.text} </span> <button onClick={() => dispatch({ type: 'DELETE_TASK', payload: task.id })}> Delete </button> </li> ))} </ul> </div> ); }
Why Reducers Are Testable
Since the reducer is a pure function with no side effects, you can test it without rendering components:
tsxtest('ADD_TASK adds a new task', () => { const state = { tasks: [], filter: 'all' as const }; const newState = taskReducer(state, { type: 'ADD_TASK', payload: 'Test' }); expect(newState.tasks).toHaveLength(1); expect(newState.tasks[0].text).toBe('Test'); expect(newState.tasks[0].done).toBe(false); });
Common Pitfalls
- Using useReducer for simple state — A single boolean toggle or a text input does not benefit from a reducer. The boilerplate of action types and a switch statement adds complexity without value. Use
useStatefor simple cases. - Mutating state inside the reducer — Reducers must return new objects, not mutate the existing state.
state.tasks.push(task)is wrong;[...state.tasks, task]is correct.
Best Practices
- Use TypeScript discriminated unions for actions — Define a union type for all possible actions. This gives you exhaustive type checking in the switch statement and catches typos in action types.
- Keep reducers pure — No API calls, no random values, no side effects inside a reducer. If you need side effects, trigger them in the component after dispatch.
Summary
useReducercentralizes complex state transitions in a pure reducer function, making them predictable and testable.- Dispatch actions (objects with a
typeand optionalpayload) to trigger state transitions. - Use reducers when state has multiple sub-values or when updates follow complex business rules.
Code Examples
import { useReducer } from 'react';
type State = { count: number };
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'incrementBy'; payload: number }
| { type: 'reset' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
case 'incrementBy': return { count: state.count + action.payload };
case 'reset': return { count: 0 };
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button onClick={() => dispatch({ type: 'incrementBy', payload: 5 })}>+5</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}