useReducer Hook
useReducer is a React hook for managing state using a reducer function instead of individual setter calls. Instead of calling many small state setters scattered across a component, you describe state changes as plain objects called actions, and a single function called a reducer decides how the state should change in response. This makes state transitions predictable, testable, and easier to reason about once a component’s state grows beyond a couple of simple values.
useReducer is especially useful when the next state depends on the previous state in a non-trivial way, when several pieces of state tend to change together, or when the same update logic is triggered from many different places in the UI.
Overview / How it works
useReducer is built on the exact same underlying mechanism as useState: React keeps a piece of state tied to the component instance, and updating it schedules a re-render. The difference is how you describe the update. With useState, you call a setter directly with the new value. With useReducer, you call a dispatch function with a small object (the action) describing what happened, and React runs your reducer function to compute the new state from the current state and that action.
A reducer is a pure function with the signature (state, action) => newState. “Pure” means it must not mutate its arguments, must not perform side effects (no fetching, no timers, no reading/writing outside variables), and must return the same output every time it’s called with the same inputs. React relies on this purity: reducers may run more than once per action in development (under Strict Mode) so React can detect accidental side effects, and the returned value is compared to the previous state by reference to decide whether to re-render.
Under the hood, when you call dispatch(action), React does not run your reducer synchronously in the middle of your event handler and immediately re-render. Instead, it queues the update. Once the current event handler finishes, React runs the reducer, computes the new state, and if that new value differs from the last rendered value, it re-renders the component: JSX is re-evaluated (React re-runs your function component), the result is diffed against the previous render output in the Virtual DOM (reconciliation), and only the parts of the real DOM that actually changed are updated (the commit phase). This is why calling dispatch multiple times synchronously in one handler is safe and efficient — React batches them and only re-renders once.
Hooks, including useReducer, must be called in the exact same order on every render of a given component. React does not track hooks by name — it tracks them by call order in an internal linked list attached to the component’s fiber. That’s why hooks can never live inside conditionals, loops, or nested functions: if the order changed between renders, React would attach the wrong stored state to the wrong hook call.
Syntax
const [state, dispatch] = useReducer(reducer, initialArg, init);
| Part | Description |
|---|---|
reducer |
A pure function (state, action) => newState that computes the next state. |
initialArg |
The initial state value, or the argument passed into init if one is provided. |
init (optional) |
A function (initialArg) => initialState used to compute the initial state lazily, useful when the initial state is expensive to construct. |
state |
The current state value, available on this render. |
dispatch |
A stable function (its identity never changes between renders) that you call with an action object to trigger a state update. |
An action is just a plain object you design yourself. By convention it has a type field describing what happened (often a string like "added" or "increment") plus any extra data the reducer needs, such as an id or a payload.
Examples
Example 1: A counter with increment, decrement, and reset
import { useReducer } from "react";
const initialState = { count: 0 };
function counterReducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
case "reset":
return initialState;
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, initialState);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</div>
);
}
export default Counter;
This renders a paragraph reading “Count: 0” and three buttons. Every click dispatches an action describing the intent (increment, decrement, or reset) rather than computing the new count inline — the counterReducer function is the single place that knows how each action affects state. Notice the reset case returns the very same initialState object, and the default case throws, which is a good habit: an unrecognized action type usually means a typo, and failing loudly surfaces the bug immediately instead of silently returning undefined.
Example 2: A todo list with add, toggle, and remove
import { useReducer, useState } from "react";
function todosReducer(todos, action) {
switch (action.type) {
case "added":
return [...todos, { id: action.id, text: action.text, done: false }];
case "toggled":
return todos.map((todo) =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
);
case "removed":
return todos.filter((todo) => todo.id !== action.id);
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
let nextId = 3;
const initialTodos = [
{ id: 1, text: "Learn useReducer", done: false },
{ id: 2, text: "Build a todo app", done: false },
];
function TodoList() {
const [todos, dispatch] = useReducer(todosReducer, initialTodos);
const [text, setText] = useState("");
function handleAdd(e) {
e.preventDefault();
if (text.trim() === "") return;
dispatch({ type: "added", id: nextId++, text });
setText("");
}
return (
<div>
<form onSubmit={handleAdd}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a task"
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.done}
onChange={() => dispatch({ type: "toggled", id: todo.id })}
/>
<span>{todo.text}</span>
</label>
<button onClick={() => dispatch({ type: "removed", id: todo.id })}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
export default TodoList;
This renders a text input with an Add button, followed by a checklist of the two initial tasks. Typing into the input and clicking Add dispatches an added action that appends a new todo object (state is never mutated — a new array is built with the spread operator). Checking a box dispatches toggled, which maps over the array and replaces only the matching todo with a new object; clicking Delete dispatches removed, which filters that item out. Notice this component combines useReducer (for the list, whose updates are more involved) with a plain useState (for the simple input text) — the two hooks are not mutually exclusive.
Example 3: Data fetching with loading/success/error state
import { useReducer, useEffect } from "react";
const initialState = { status: "idle", data: null, error: null };
function fetchReducer(state, action) {
switch (action.type) {
case "fetch_start":
return { status: "loading", data: null, error: null };
case "fetch_success":
return { status: "success", data: action.payload, error: null };
case "fetch_error":
return { status: "error", data: null, error: action.error };
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
function UserProfile({ userId }) {
const [state, dispatch] = useReducer(fetchReducer, initialState);
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
dispatch({ type: "fetch_start" });
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!response.ok) throw new Error("Request failed");
const payload = await response.json();
dispatch({ type: "fetch_success", payload });
} catch (error) {
if (error.name !== "AbortError") {
dispatch({ type: "fetch_error", error: error.message });
}
}
}
loadUser();
return () => controller.abort();
}, [userId]);
if (state.status === "loading") return <p>Loading...</p>;
if (state.status === "error") return <p>Error: {state.error}</p>;
if (state.status === "success") return <p>Name: {state.data.name}</p>;
return null;
}
export default UserProfile;
This groups three related pieces of state — status, data, and error — into one state object so they can never fall out of sync (for example, you can never end up with status: "success" and a stale error left over from a previous request, because every action replaces the whole state object at once). This is a common reason to reach for useReducer over several separate useState calls: it becomes impossible to represent invalid in-between combinations. The effect’s cleanup function aborts an in-flight request if userId changes or the component unmounts, and the AbortError is deliberately ignored so a cancelled request doesn’t dispatch a stale error.
How it works step by step
On mount: React calls your component function for the first time. It sees the useReducer call, has no prior state stored for it, so it computes the initial state (either initialArg directly, or by calling init(initialArg) if you passed a lazy initializer) and stores it. The component renders using that state, and a stable dispatch function is created and attached to this hook slot.
On dispatch: Calling dispatch(action) does not run the reducer immediately in place — React schedules an update. Once your current code (the event handler, effect, etc.) finishes running, React calls reducer(currentState, action), gets back newState, and compares it to the current state. If it’s different (by reference for objects/arrays, by value for primitives), React marks the component as needing to re-render.
On re-render: React re-runs the component function. The useReducer call now returns the freshly computed state on this render. JSX is re-evaluated using the new state, producing a new Virtual DOM tree. React reconciles this against the previous tree — diffing element by element — and computes the minimal set of real DOM mutations needed, then applies them in the commit phase. Only the DOM nodes that actually changed (for example, a text node’s content) are touched.
On unmount: React discards the fiber holding this component’s hook state entirely, including whatever the reducer’s state currently was. There is no reducer-level cleanup step — cleanup for side effects (subscriptions, timers, in-flight requests) still belongs in useEffect cleanup functions, as shown in Example 3.
Common Mistakes
Mutating state inside the reducer
function badReducer(state, action) {
switch (action.type) {
case "increment":
state.count = state.count + 1;
return state;
default:
return state;
}
}
This mutates the existing state object in place instead of creating a new one. Because React compares the returned value to the previous state by reference, and here the reference is unchanged (it’s literally the same object), React may conclude nothing changed and skip re-rendering — or, worse, other parts of the app that captured a reference to the old state object see it change unexpectedly. Always return a new object or array:
function reducer(state, action) {
switch (action.type) {
case "increment":
return { ...state, count: state.count + 1 };
default:
return state;
}
}
No default case (or a default that silently swallows unknown actions)
function reducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
}
}
If action.type doesn’t match "increment", this function falls off the end of the switch and returns undefined — silently wiping out your state on the next render. Always add a default that either returns the unchanged state (for actions this reducer intentionally ignores) or throws an error (to catch typos in action.type during development), as shown in every example above.
Putting side effects inside the reducer
A reducer must be pure: no fetch, no setTimeout, no reading Date.now() or Math.random(), no logging that anything depends on. Side effects belong in event handlers or useEffect, which then dispatch a plain action describing what happened (as in Example 3, where the effect performs the fetch and dispatches fetch_success/fetch_error — the reducer itself just merges the result into state).
Best Practices
- Reach for
useReducerwhen state updates are complex, when several values change together, or when the same transition logic is triggered from many different UI elements — for simple independent values, plainuseStateis simpler and just as correct. - Keep the reducer function pure: no side effects, no mutation, deterministic output for a given
(state, action)pair. - Model actions as plain objects with a descriptive
type(e.g."added","toggled") plus any extra data the reducer needs. - Always include a
defaultcase; throwing on an unrecognized action type surfaces typos immediately instead of producing silent bugs. - Use the lazy
initargument when computing the initial state is expensive, so that work only happens once on mount rather than on every render. - Extract a reducer and its actions into their own module when a component grows large — it keeps the component’s JSX focused on rendering and makes the reducer independently unit-testable, since it’s a pure function.
- Combine
useReducerwithuseContextwhen several deeply nested components need to dispatch actions against the same state, avoiding prop-drillingdispatchthrough every intermediate component.
Practice Exercises
- Build a
useReducer-powered shopping cart with actions"added","removed", and"changed_quantity", where the state is an array of{ id, name, price, quantity }objects. Render the total price by deriving it from state during render (not by storing it separately in the reducer). - Convert a component that currently uses three separate
useStatecalls (e.g.name,email,errorsin a form) into a singleuseReducer, with one action type per field update and one action type that sets all validation errors at once. - Write a traffic-light reducer with state
"red","yellow", or"green"and a single action type"next"that cycles through the three colors in order. Render the current color and a button that dispatches"next".
Summary
useReducermanages state through a pure(state, action) => newStatefunction, dispatched to via a stabledispatchfunction.- It’s the better choice over multiple
useStatecalls when updates are complex, interdependent, or triggered from many places. - Reducers must never mutate state or perform side effects — always return a new value, and keep fetches/timers in effects or handlers that then dispatch plain actions.
- React batches dispatched updates, re-renders the component with the new state, reconciles the resulting Virtual DOM against the previous tree, and commits only the necessary DOM changes.
- Always include a
defaultcase in the reducer’s switch statement to catch unrecognized or mistyped action types early. - Pair
useReducerwithuseContextto share dispatch-driven state across deeply nested components without prop drilling.
