Learn useReducer for managing complex state with named transitions — and how it pairs with useContext to replace Redux in mid-sized apps.
Think of a vending machine. You press a button (an action), and the machine's internal mechanism (the reducer) takes the machine's current state and your button press, then produces a new state. The machine doesn't let you reach inside and change its parts directly. You can only interact with it through its defined interface — the buttons. useReducer works identically. You have a current state (what the vending machine currently has inside). You dispatch an action (press a button). The reducer function — a pure function you write — takes the current state and the action, and returns the next state. You never mutate state directly. You describe what happened, and the reducer decides what changes. The key difference from useState: instead of calling setCount(42) — directly specifying the new value — you call dispatch({ type: 'INCREMENT' }) — describing what happened. The reducer then decides what INCREMENT means for the current state. This separation of "what happened" from "what changes" makes complex state logic readable, testable, and debuggable. When should you reach for useReducer instead of useState? When multiple state fields update together based on the same event. When the next state depends on the previous state in non-trivial ways. When you want to be able to test your state logic independently of your components. When you want to name your transitions.
import { useReducer } from 'react'
// 1. Define state shape
const initialState = { count: 0, step: 1 }
// 2. Define reducer — pure function: (state, action) => newState
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + state.step }
case 'DECREMENT':
return { ...state, count: state.count - state.step }
case 'RESET':
return initialState
case 'SET_STEP':
return { ...state, step: action.payload }
default:
throw new Error('Unknown action: ' + action.type)
}
}
// 3. Use in component
function Counter() {
const [state, dispatch] = useReducer(counterReducer, initialState)
return (
<div>
<p>Count: {state.count} (step: {state.step})</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
<input
type="number"
value={state.step}
onChange={e => dispatch({ type: 'SET_STEP', payload: Number(e.target.value) })}
/>
</div>
)
}
| Use useState when | Use useReducer when |
|---|---|
| Simple, independent values | Multiple fields update together |
| Direct value replacement | Next state depends on previous in complex ways |
| 2-3 unrelated state variables | State transitions benefit from names |
| No complex logic needed | State logic is testable in isolation |
| No interdependencies | Several handlers share state mutations |
// Signal to switch: you're calling 3+ setters in one handler
function handleSubmit() {
setLoading(true) // ← 4 setters
setError(null)
setData(null)
setSubmitted(false)
// This should be one dispatch({ type: 'SUBMIT_START' })
}
const initialFormState = {
values: { name: '', email: '', password: '' },
errors: {},
loading: false,
submitted: false,
}
function formReducer(state, action) {
switch (action.type) {
case 'FIELD':
return {
...state,
values: { ...state.values, [action.field]: action.value },
errors: { ...state.errors, [action.field]: '' } // clear field error on change
}
case 'VALIDATE':
return { ...state, errors: action.errors }
case 'SUBMIT_START':
return { ...state, loading: true, errors: {} }
case 'SUBMIT_SUCCESS':
return { ...state, loading: false, submitted: true }
case 'SUBMIT_ERROR':
return { ...state, loading: false, errors: action.errors }
case 'RESET':
return initialFormState
default:
return state
}
}
function SignupForm() {
const [state, dispatch] = useReducer(formReducer, initialFormState)
async function handleSubmit(e) {
e.preventDefault()
const errors = validate(state.values)
if (Object.keys(errors).length) {
dispatch({ type: 'VALIDATE', errors })
return
}
dispatch({ type: 'SUBMIT_START' })
try {
await api.signup(state.values)
dispatch({ type: 'SUBMIT_SUCCESS' })
} catch (err) {
dispatch({ type: 'SUBMIT_ERROR', errors: { global: err.message } })
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={state.values.name}
onChange={e => dispatch({ type: 'FIELD', field: 'name', value: e.target.value })}
/>
{state.errors.global && <p className="error">{state.errors.global}</p>}
<button disabled={state.loading}>
{state.loading ? 'Signing up...' : 'Sign Up'}
</button>
</form>
)
}
function init(initialCount) {
// Called once on mount — can do expensive setup
return { count: initialCount, history: [] }
}
function Counter({ initialCount = 0 }) {
// Third arg: init function called with second arg (initialCount)
const [state, dispatch] = useReducer(counterReducer, initialCount, init)
// init(0) → { count: 0, history: [] }
// Also enables clean reset with dispatch
function handleReset() {
dispatch({ type: 'reset', payload: initialCount })
}
}
function counterReducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1, history: [...state.history, state.count] }
case 'reset': return init(action.payload) // reuse init function!
}
}
Pure reducer functions can be unit tested directly without mounting any component — no React Test Renderer, no act(), no async rendering:
// The reducer — no React imports
function cartReducer(state, action) {
switch (action.type) {
case 'ADD':
const existing = state.items.find(i => i.id === action.item.id)
if (existing) return {
...state,
items: state.items.map(i => i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i)
}
return { ...state, items: [...state.items, { ...action.item, qty: 1 }] }
case 'REMOVE':
return { ...state, items: state.items.filter(i => i.id !== action.id) }
case 'CLEAR':
return { items: [] }
default:
return state
}
}
// Pure unit tests — zero React overhead
describe('cartReducer', () => {
it('adds a new item', () => {
const state = { items: [] }
const next = cartReducer(state, { type: 'ADD', item: { id: 1, name: 'Book', price: 10 } })
expect(next.items).toHaveLength(1)
expect(next.items[0].qty).toBe(1)
})
it('increments qty for existing item', () => {
const state = { items: [{ id: 1, qty: 1 }] }
const next = cartReducer(state, { type: 'ADD', item: { id: 1 } })
expect(next.items[0].qty).toBe(2)
})
})
// Full global state pattern without any external library
const StoreContext = createContext(null)
const DispatchContext = createContext(null)
const initialState = { user: null, theme: 'light', cart: [] }
function appReducer(state, action) {
switch (action.type) {
case 'LOGIN': return { ...state, user: action.user }
case 'LOGOUT': return { ...state, user: null, cart: [] }
case 'TOGGLE_THEME': return { ...state, theme: state.theme === 'light' ? 'dark' : 'light' }
case 'ADD_TO_CART': return { ...state, cart: [...state.cart, action.item] }
default: return state
}
}
export function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState)
return (
<DispatchContext.Provider value={dispatch}> {/* stable — never re-renders */}
<StoreContext.Provider value={state}>
{children}
</StoreContext.Provider>
</DispatchContext.Provider>
)
}
// Custom hooks for each concern
export const useStore = () => useContext(StoreContext)
export const useDispatch = () => useContext(DispatchContext)
// Any component — reads state
function CartCount() {
const { cart } = useStore()
return <span>{cart.length}</span>
}
// Any component — dispatches actions
function AddButton({ item }) {
const dispatch = useDispatch()
return <button onClick={() => dispatch({ type: 'ADD_TO_CART', item })}>Add</button>
// This component NEVER re-renders when state changes — DispatchContext is stable
}
import produce from 'immer'
// Without Immer — verbose nested updates
function reducer(state, action) {
switch (action.type) {
case 'UPDATE_CITY':
return {
...state,
user: { ...state.user, address: { ...state.user.address, city: action.city } }
}
}
}
// With Immer — write mutating code, get immutable result
const reducer = produce((draft, action) => {
switch (action.type) {
case 'UPDATE_CITY':
draft.user.address.city = action.city // looks like mutation — Immer handles immutability!
break
case 'ADD_TAG':
draft.post.tags.push(action.tag) // direct array push — Immer makes it immutable
break
}
})Many developers think useReducer is just a more complex useState — but it's a fundamentally different model. useState says "set this value to X". useReducer says "this event happened — you decide what changes". The reducer centralises all state logic in one place, making transitions explicit, named, and testable.
Many developers think reducers can contain side effects like API calls — but reducers must be pure functions. No fetch, no localStorage, no console.log with side effects. Side effects go in useEffect or in the event handlers that dispatch actions. The reducer receives an action result (e.g., fetched data) as part of the action payload — it doesn't perform the fetch itself.
Many developers think dispatch is asynchronous like setState — but dispatch is synchronous. After calling dispatch, the state update is scheduled immediately (not applied synchronously), but dispatch itself returns undefined and doesn't give you a way to await the state update. The pattern for async is: dispatch loading action → perform async operation → dispatch success/error action.
Many developers think using useReducer requires TypeScript — the pattern works in plain JavaScript too. TypeScript makes it better (action union types, exhaustive switch, typed initial state) but it's not required. The pattern's benefits come from the architecture, not the type system.
Many developers think useReducer is always more complex than useState — for simple state, it is. But for forms, multi-step flows, undo/redo, and loading/error/success patterns, useReducer is actually simpler because all the logic lives in one function instead of scattered across multiple event handlers.
Shopping carts use useReducer with actions like ADD_ITEM, REMOVE_ITEM, UPDATE_QUANTITY, APPLY_COUPON, and CLEAR_CART. Each action updates multiple fields atomically — total, count, discounts, and the items array all update consistently in a single reducer call without risks of partial state corruption.
Multi-step checkout flows use useReducer to manage steps, form data, validation errors, loading states, and payment status in a single reducible state object. Each step transition dispatches an action, making it trivial to go back to any previous step and restore its exact state.
Undo/redo functionality is the textbook useReducer use case. The state shape includes a present and a history array. UNDO pops the last action from history and reverts to the previous state. REDO reapplies. This is impossible to implement cleanly with useState — useReducer's centralised state transitions make it natural.
Real-time games (turn-based, puzzles) use useReducer where each player action is a dispatch call. The game board state, current player, score, and game phase all update together in the reducer. The pure function structure makes replaying a game from a log of actions trivial.
Data tables with sorting, filtering, pagination, and column visibility use useReducer where SORT, FILTER, PAGINATE, TOGGLE_COLUMN are all named actions. This makes the table's state machine explicit and makes it easy to add features like saved table configurations or URL-synced state.
No questions tagged to this topic yet.
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.