Learn the reducer pattern for managing complex state in React. Understand when to use useReducer over useState, how to dispatch actions, and how to combine reducers with context.
useState is perfect for simple, independent pieces of state: a counter, a toggle, a form field. But as your component grows, you may find yourself juggling many related state variables that change in coordinated ways.
Consider a todo list component:
function TodoApp() {
const [todos, setTodos] = React.useState([]);
const [filter, setFilter] = React.useState('all');
const [editingId, setEditingId] = React.useState(null);
function addTodo(text) {
setTodos([...todos, { id: Date.now(), text, done: false }]);
}
function toggleTodo(id) {
setTodos(todos.map(t => t.id === id ? { ...t, done: !t.done } : t));
}
function deleteTodo(id) {
setTodos(todos.filter(t => t.id !== id));
if (editingId === id) setEditingId(null); // coordinated update!
}
// ... more handlers
}Notice how deleteTodo needs to update two pieces of state, and how each handler contains logic for computing the next state. As the component grows, these update functions become complex and error-prone.
This is where useReducer shines. It lets you:
A reducer is a pure function that takes the current state and an action, and returns the new state:
(state, action) => newState
This pattern comes from the Array.reduce() method and is the foundation of libraries like Redux. The key idea is that all state transitions are described by actions — plain objects that say what happened.
Actions are plain JavaScript objects with a type property (a string that describes the event) and an optional payload (additional data):
{ type: 'ADD_TODO', payload: { text: 'Learn React' } }
{ type: 'TOGGLE_TODO', payload: { id: 1 } }
{ type: 'DELETE_TODO', payload: { id: 1 } }
{ type: 'CLEAR_ALL' } // no payload neededThe reducer examines the action type and computes the new state accordingly:
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, {
id: Date.now(),
text: action.payload.text,
done: false,
}],
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, done: !todo.done }
: todo
),
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload.id),
};
default:
return state;
}
}Important rules for reducers:
localStorage, no Math.random())....state) to copy existing properties.default case should return the current state unchanged.React's useReducer hook takes a reducer function and an initial state, and returns the current state plus a dispatch function:
const [state, dispatch] = React.useReducer(reducer, initialState);state — the current state valuedispatch — a function you call to send actions to the reducerTo trigger a state update, call dispatch with an action object:
function TodoApp() {
const [state, dispatch] = React.useReducer(todoReducer, { todos: [] });
function handleAdd(text) {
dispatch({ type: 'ADD_TODO', payload: { text } });
}
function handleToggle(id) {
dispatch({ type: 'TOGGLE_TODO', payload: { id } });
}
return (
<div>
<button onClick={() => handleAdd('New task')}>Add</button>
<ul>
{state.todos.map(todo => (
<li
key={todo.id}
onClick={() => handleToggle(todo.id)}
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
>
{todo.text}
</li>
))}
</ul>
</div>
);
}When you call dispatch, React:
Use useState when... | Use useReducer when... |
|---|---|
| State is a single value (number, string, boolean) | State is an object or array with multiple sub-values |
| Updates are simple (set to a new value) | Updates depend on the previous state in complex ways |
| Few state transitions | Many different state transitions |
| Logic is straightforward | You want to centralize and test update logic |
One of the most powerful patterns in React is combining useReducer with Context. This gives you a simple, built-in state management solution that can replace Redux for many use cases.
The idea: store your reducer's state and dispatch in context so that any component in the tree can read state or dispatch actions without prop drilling.
const TodoContext = React.createContext();
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, {
id: Date.now(),
text: action.payload.text,
done: false,
}],
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(t =>
t.id === action.payload.id ? { ...t, done: !t.done } : t
),
};
default:
return state;
}
}
function TodoProvider({ children }) {
const [state, dispatch] = React.useReducer(todoReducer, { todos: [] });
return (
<TodoContext.Provider value={{ state, dispatch }}>
{children}
</TodoContext.Provider>
);
}
// Any deeply nested component can dispatch actions:
function AddButton() {
const { dispatch } = React.useContext(TodoContext);
return (
<button onClick={() => dispatch({
type: 'ADD_TODO',
payload: { text: 'New task' }
})}>
Add Todo
</button>
);
}
// Any deeply nested component can read state:
function TodoList() {
const { state } = React.useContext(TodoContext);
return (
<ul>
{state.todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
}This pattern is lightweight and does not require any third-party library. It works well for medium-sized applications. For very large applications with frequent updates, dedicated state management libraries may offer better performance through selective subscriptions.
<div id="root"></div>
<script src="https://unpkg.com/react@19/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@19/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
function counterReducer(state, action) {
switch (action.type) {
case "INCREMENT":
return { ...state, count: state.count + 1 };
case "DECREMENT":
return { ...state, count: state.count - 1 };
case "RESET":
return { ...state, count: 0 };
case "SET":
return { ...state, count: action.payload };
default:
return state;
}
}
function App() {
const [state, dispatch] = React.useReducer(counterReducer, { count: 0 });
return (
<div>
<h2>useReducer Counter</h2>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "INCREMENT" })}>+1</button>
<button onClick={() => dispatch({ type: "DECREMENT" })}>-1</button>
<button onClick={() => dispatch({ type: "RESET" })}>Reset</button>
<button onClick={() => dispatch({ type: "SET", payload: 100 })}>Set to 100</button>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>What are the two arguments that a reducer function receives?