What is useReducer in React?
useReducer is a React hook that manages state through a pure reducer function: it takes the current state and an action, then returns the next state.
Theory
TL;DR
- Think vending machine: you dispatch an action (coin + button press), the reducer computes new state (dispenses snack), and the machine itself never changes mid-process.
- Main difference from
useState: all state transition logic lives in one function outside the component, not scattered across event handlers. - Use
useReducerwhen you have 3+ update types or when one field change affects another. - For a single boolean or a simple counter,
useStateis less boilerplate. - The reducer is a plain function. No React imports needed to unit-test it.
Quick Example
import { useReducer } from 'react';
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
default: return state; // always handle unknown actions
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
<p>Count: {state.count}</p> {/* starts at 0 */}
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
</>
);
}dispatch({ type: 'increment' }) sends an action to the reducer. The reducer returns { count: 1 }. React detects the new state and re-renders. That is the whole cycle.
Key Difference from useState
useState works well for one or two independent values. When state is an object with several fields and a change to one field depends on another, event handlers start carrying too much logic. useReducer moves that logic into one function you can read, test, and reason about without mounting the component. The component stays clean because it only calls dispatch, not the update logic itself.
When to Use
- 1-2 simple setters with no conditions between them - use
useState. - State object with 3+ distinct update types (add, remove, reset, filter) - use
useReducer. - One field depends on another (toggle only if not loading, increment only below a limit) - use
useReducer. - You want to test state transitions without a component - test the reducer function as a plain function.
- Migrating from Redux -
useReducerfollows the same pattern.
useState vs useReducer
| Feature | useState | useReducer |
|---|---|---|
| Best for | Primitive values, independent fields | Complex objects, multiple actions |
| How you update | setCount(n) | dispatch({ type: 'inc' }) |
| Where logic lives | Inside event handlers | Single reducer function |
| Testability | Test via component | Test reducer as a plain function |
| React 18 batching | Fine for most cases | Handles deep or batched updates better |
| When to switch | 1-2 setters | 3+ actions, interdependent state |
How React Handles dispatch
React queues dispatch calls and batches them in concurrent mode (React 18+). On the next fiber commit, React runs the reducer synchronously with the latest state and the queued action, computes the next state, and schedules a re-render only if the result differs from the current state. The dispatch function itself is stable across renders - you do not need to include it in dependency arrays.
One thing I have seen trip teams up in production: the reducer runs synchronously, so fetching data or calling APIs inside it is not possible. The reducer must be pure.
Common Mistakes
1. Mutating state directly in the reducer
// wrong
function badReducer(state, action) {
state.count++; // mutates shared state directly
return state; // returns the same reference
}React uses a shallow comparison. Returning the same object reference means no re-render happens. Always return a new object:
// correct
case 'increment': return { ...state, count: state.count + 1 };2. Calling dispatch inside the render body
// wrong - infinite loop
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
dispatch({ type: 'tick' }); // runs every render, which triggers another render
}Move dispatch into event handlers or useEffect.
3. Forgetting the default case
// wrong
function reducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
// no default - unknown actions silently return undefined
}
}Always add default: return state. Without it, any unrecognized action returns undefined and breaks the component in ways that are hard to trace.
4. Passing an updater function as the action
// wrong - useState habit carried over
dispatch(count => count + 1); // useReducer passes this function as the action object itself
// correct
dispatch({ type: 'increment' }); // compute new value inside the reduceruseReducer does not accept updater functions. The second argument to the reducer is always the action you dispatched.
5. Using useReducer for a single boolean
// overkill
const [state, dispatch] = useReducer(reducer, { isOpen: false });
dispatch({ type: 'toggle' });
// simpler
const [isOpen, setIsOpen] = useState(false);
setIsOpen(prev => !prev);More boilerplate than the problem it solves. Switch to useReducer when you actually need it.
Real-World Usage
- React docs TodoMVC pattern: one reducer handles add, toggle, and filter actions for the todo list.
- Async data fetching: dispatch
loading,success,erroras the fetch progresses - cleaner than three separateuseStatecalls. - Next.js App Router: pairs
useReducerwithuseEffectfor client-side state after server mutations. - Zustand: uses reducer-style functions internally for state slices.
- Decision rule from the React team: if your component needs more than 3 setters or you write
if/switchlogic inside event handlers, switch touseReducer.
Follow-Up Questions
Q: When would you choose useReducer over useState?
A: When state updates are interdependent. For example, a form where submitting should set loading: true and clear error at the same time. With useState those are two separate setter calls that can go out of sync. With useReducer one dispatch({ type: 'submit' }) handles both atomically.
Q: What makes a reducer "pure" and why does it matter?
A: Pure means the function only reads from state and action, returns a new object, and has no side effects. Purity is what makes React 18 batching safe - React can replay reducer calls without breaking state.
Q: How do you handle async operations with useReducer?
A: The reducer stays synchronous. Dispatch { type: 'loading' } before the fetch, then { type: 'success', payload: data } or { type: 'error', payload: message } in .then/.catch. useEffect owns the async part; useReducer owns the state transitions.
Q: How is useReducer different from Redux?
A: useReducer is local to one component - no global store, no provider, no middleware. Redux adds a global store, devtools integration, and middleware like redux-thunk. For component-level logic useReducer is enough; for state shared across many components you combine it with Context or use Redux/Zustand.
Q: (Senior) How would you implement optimistic updates with error rollback using useReducer?
A: Snapshot the current state before the action. Dispatch { type: 'optimistic_update', data: newData } to show the change immediately. On API error, dispatch { type: 'rollback', snapshot: previousState } and the reducer restores the snapshot. No extra library needed.
Examples
Basic: Counter with three actions
import { useReducer } from 'react';
function reducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
case 'reset': return { count: 0 };
default: return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}Three actions in one reducer. Adding a fourth - like set with a specific value - means editing only the reducer, not the component.
Intermediate: Todo list with filter state
import { useReducer } from 'react';
const initialState = { todos: [], filter: 'all' };
function todosReducer(state, action) {
switch (action.type) {
case 'add_todo':
return {
...state,
todos: [...state.todos, { id: Date.now(), text: action.text, done: false }]
};
case 'toggle':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
)
};
case 'set_filter':
return { ...state, filter: action.filter };
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todosReducer, initialState);
const visible = state.filter === 'all'
? state.todos
: state.todos.filter(t => t.done === (state.filter === 'done'));
return (
<div>
<input
onKeyDown={e => {
if (e.key === 'Enter') {
dispatch({ type: 'add_todo', text: e.target.value });
e.target.value = '';
}
}}
placeholder="Add todo..."
/>
<ul>
{visible.map(todo => (
<li
key={todo.id}
onClick={() => dispatch({ type: 'toggle', id: todo.id })}
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
>
{todo.text}
</li>
))}
</ul>
<button onClick={() => dispatch({ type: 'set_filter', filter: 'all' })}>All</button>
<button onClick={() => dispatch({ type: 'set_filter', filter: 'done' })}>Done</button>
</div>
);
}Three related fields - todos, filter, and the computed visible list - managed in one place. Try building this with useState and count how many places you touch for each new feature.
Advanced: Async fetch with loading and error states
import { useReducer, useEffect } from 'react';
const initialState = { data: null, loading: false, error: null };
function asyncReducer(state, action) {
switch (action.type) {
case 'loading':
return { ...state, loading: true, error: null };
case 'success':
return { data: action.payload, loading: false, error: null };
case 'error':
return { ...state, loading: false, error: action.payload };
default:
return state;
}
}
function UserProfile({ userId }) {
const [state, dispatch] = useReducer(asyncReducer, initialState);
useEffect(() => {
dispatch({ type: 'loading' });
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => dispatch({ type: 'success', payload: data }))
.catch(err => dispatch({ type: 'error', payload: err.message }));
}, [userId]);
if (state.loading) return <p>Loading...</p>;
if (state.error) return <p>Error: {state.error}</p>;
return <pre>{JSON.stringify(state.data, null, 2)}</pre>;
}The reducer never touches fetch. The useEffect never touches state directly. Each part has exactly one job - this pattern holds up well as the component grows.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.