Skip to main content

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 useReducer when you have 3+ update types or when one field change affects another.
  • For a single boolean or a simple counter, useState is less boilerplate.
  • The reducer is a plain function. No React imports needed to unit-test it.

Quick Example

jsx
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 - useReducer follows the same pattern.

useState vs useReducer

FeatureuseStateuseReducer
Best forPrimitive values, independent fieldsComplex objects, multiple actions
How you updatesetCount(n)dispatch({ type: 'inc' })
Where logic livesInside event handlersSingle reducer function
TestabilityTest via componentTest reducer as a plain function
React 18 batchingFine for most casesHandles deep or batched updates better
When to switch1-2 setters3+ 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

jsx
// 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:

jsx
// correct case 'increment': return { ...state, count: state.count + 1 };

2. Calling dispatch inside the render body

jsx
// 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

jsx
// 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

jsx
// 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 reducer

useReducer does not accept updater functions. The second argument to the reducer is always the action you dispatched.

5. Using useReducer for a single boolean

jsx
// 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, error as the fetch progresses - cleaner than three separate useState calls.
  • Next.js App Router: pairs useReducer with useEffect for 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/switch logic inside event handlers, switch to useReducer.

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

jsx
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

jsx
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

jsx
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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?