What you'll learn
Quick Answer
Reach for useReducer when several pieces of state always change together, or when the next state depends on the current one in more than a trivial way. You write one pure function that takes the current state and an action and returns the next state, then call dispatch instead of many setters. The reducer must return a new object; if you mutate the existing state and return it, React sees the same reference and may skip the re-render entirely.
The bug: related state that updates in pieces
Most React state starts simple. A component needs one flag, you add useState, it works. The trouble begins when three pieces of state are really describing one thing.
Here is a course list that loads from an API, written the way almost everyone writes it first:
function CourseList() {
const [loading, setLoading] = useState(false);
const [courses, setCourses] = useState([]);
const [error, setError] = useState(null);
async function load() {
setLoading(true);
try {
const res = await fetch("/api/courses");
setCourses(await res.json());
} catch (err) {
setError(err.message);
}
setLoading(false);
}
}Now count the ways this goes wrong. On a retry you set loading to true, but nothing in the code clears error or courses, so the spinner and the previous error message render together. On failure the old course array stays on screen underneath the error. Nothing in the code prevents loading === true and error !== null at the same time, even though that combination is meaningless.
These are impossible states, and they exist because the three useState calls know nothing about each other. Every place you change one, you are personally responsible for changing the other two correctly. Add a fourth flag such as isRefreshing and the number of places you can get it wrong grows again.
useReducer fixes this by moving every transition into a single function. Instead of scattering setters across handlers, effects and callbacks, you describe what happened, and one function decides what the state becomes. There is exactly one place to read, and exactly one place to fix.
The reducer pattern in full
A reducer is an ordinary function with the shape (state, action) => newState. It must be pure: no fetch calls, no timers, no reading from localStorage, no random numbers. Same inputs, same output, every time. React may call it more than once for the same dispatch during development, so impurity shows up as strange double effects.
const initialState = { status: "idle", courses: [], error: null };
function reducer(state, action) {
switch (action.type) {
case "FETCH_START":
return { status: "loading", courses: [], error: null };
case "FETCH_SUCCESS":
return { status: "done", courses: action.payload, error: null };
case "FETCH_ERROR":
return { status: "error", courses: [], error: action.error };
default:
return state;
}
}
function CourseList() {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
let cancelled = false;
dispatch({ type: "FETCH_START" });
fetch("/api/courses")
.then((r) => r.json())
.then((data) => {
if (!cancelled) dispatch({ type: "FETCH_SUCCESS", payload: data });
})
.catch((err) => {
if (!cancelled) dispatch({ type: "FETCH_ERROR", error: err.message });
});
return () => { cancelled = true; };
}, []);
if (state.status === "loading") return <p>Loading courses...</p>;
if (state.status === "error") return <p>{state.error}</p>;
return <ul>{state.courses.map((c) => <li key={c.id}>{c.title}</li>)}</ul>;
}Now the loading branch cannot show an error, because the only way to enter loading also clears the error. The illegal combination is unreachable, not merely avoided by discipline.
The failure mode to memorise is mutation. React compares the value your reducer returns with the previous state using Object.is. If you push into the existing array and return the same object, the references match and React is free to skip re-rendering the subtree. Your data changed and the screen did not.
// wrong: same object comes back, screen does not update
case "ADD":
state.courses.push(action.course);
return state;
// right: a fresh object and a fresh array
case "ADD":
return { ...state, courses: [...state.courses, action.course] };If the initial state is expensive to build, pass a third argument: useReducer(reducer, rawProp, init) calls init(rawProp) once instead of on every render.
Design actions as events, not as setters
The most common way to get no benefit at all from useReducer is to write actions that are just renamed setters: SET_NAME, SET_EMAIL, SET_LOADING. That is the same scattered logic as before, now with extra typing. The component still has to know the right sequence of updates.
Name actions after what the user or the system did. In a cart for a Pune-based store, the useful actions are ADD_TO_CART, REMOVE_FROM_CART, APPLY_COUPON, CHECKOUT_STARTED. The reducer then owns the consequences, including the ones a component would forget:
function cartReducer(state, action) {
switch (action.type) {
case "ADD_TO_CART": {
const existing = state.items.find((i) => i.id === action.item.id);
const items = existing
? state.items.map((i) =>
i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i
)
: [...state.items, { ...action.item, qty: 1 }];
return { ...state, items, coupon: null };
}
case "APPLY_COUPON":
return { ...state, coupon: action.code };
default:
return state;
}
}
// inside the component:
// const [state, dispatch] = useReducer(cartReducer, { items: [], coupon: null });
const total = state.items.reduce((sum, i) => sum + i.price * i.qty, 0);Notice that adding an item also clears the coupon, because the discount was calculated for a different basket. With separate setters, that rule lives in whichever handler the developer remembered. Here it lives in one place and applies everywhere, including the "buy again" button someone adds next month.
Two more rules worth following. Do not store derived values such as total in state; compute them during render from the items, otherwise you get a total that disagrees with the list. And always return state from the default branch rather than throwing, unless you genuinely want an unknown action to crash the app.
One behaviour surprises people coming from other frameworks: dispatch does not update state immediately. Reading state.items.length on the line after a dispatch gives the old value, exactly like useState. If you need the new value, compute it in the reducer or read it on the next render.
Combining useReducer with Context
A reducer inside one component is local state. To share it across a screen without threading state and dispatch through six layers of props, put it in Context. The pattern that works well is two contexts, not one.
const CartStateContext = createContext(null);
const CartDispatchContext = createContext(null);
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, { items: [], coupon: null });
return (
<CartStateContext.Provider value={state}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartStateContext.Provider>
);
}
export const useCart = () => useContext(CartStateContext);
export const useCartDispatch = () => useContext(CartDispatchContext);Why split them? Every consumer of a context re-renders when that context value changes. A header button that only needs to add an item does not care what is in the cart. React guarantees that the dispatch function identity stays stable across renders, so a component that reads only CartDispatchContext never re-renders because of a cart change. Put state and dispatch in one object and you throw that away, because the object literal is new on every render.
The rest of the value of this pattern is testing. Your reducer is a plain function that imports nothing from React, so a test is three lines with no rendering, no mock provider and no test library:
const next = cartReducer(
{ items: [], coupon: "WELCOME" },
{ type: "ADD_TO_CART", item: { id: 1, price: 499 } }
);
// next.items.length === 1, next.coupon === nullThat is a genuinely nice thing to be able to show in an interview: the business rules of your cart are testable without a browser.
useReducer vs Redux, and when you need neither
People often assume useReducer is "Redux built into React". The mental model is the same, actions in and a new state out, but the surrounding machinery is not.
- Scope. A reducer's state lives in one component's tree and dies when that component unmounts. A Redux store lives outside React and survives route changes.
- Middleware. Redux has an interception point for logging, persistence, retries and async flows.
useReducerhas none, so async work stays in effects and handlers. - Devtools. Redux gives you an action log and time-travel debugging. With
useReduceryou get the React DevTools state view and whatever you log yourself. - Selectors. Redux libraries let a component subscribe to one slice. Context re-renders every consumer when the value changes.
So the honest rule is: if the state is used by a few components on one screen, useReducer is enough and adds no dependency. If many unrelated screens read and write the same data, and you want a log of every change, a real store earns its place. Modern Redux Toolkit reducers even let you write mutating-looking code, because a proxy converts it into a new object underneath. That safety net does not exist in plain useReducer, which is exactly why the mutation bug is so common there.
And there is a third answer that often beats both. A large share of the state people put in reducers is server data: lists, profiles, orders. That is cached remote state, not application state, and a data-fetching library handles caching, revalidation and loading flags better than any reducer you write by hand. Use a reducer for state your app genuinely owns, such as a multi-step form, a filter panel, an undo stack or a game board.
