useReducer Hook
useState is perfect for simple values — a boolean, a string, a number. But as state grows more complex — multiple related fields, many ways to mutate it, transitions that depend on the previous state — you end up writing scattered setState calls that are hard to test and reason about.
useReducer is an alternative to useState that moves all state transitions into a single pure function called a reducer. It is the same pattern at the heart of Redux, but built directly into React with no extra dependencies.
The Signature
const [state, dispatch] = useReducer(reducer, initialState)
reducer— a pure function(state, action) => newStateinitialState— the value state starts atstate— the current state value (like the first element fromuseState)dispatch— a function you call with an action to trigger a state change
The Reducer Function
A reducer takes the current state and an action object, and returns the next state. It must be a pure function — no side effects, no mutations, just return a new value based on the inputs.
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:
// Always handle the default to avoid silent bugs
throw new Error('Unknown action: ' + action.type)
}
}A Simple Counter
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 }
case 'reset':
return { count: 0 }
default:
throw new Error('Unknown action: ' + action.type)
}
}
export function Counter() {
const [state, dispatch] = useReducer(reducer, 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>
)
}Actions with Payload
Actions can carry additional data in a payload field. This lets one action type handle different values:
// Dispatch an action with a payload
dispatch({ type: 'setCount', payload: 42 })
dispatch({ type: 'addItem', payload: { id: 1, name: 'Apple', price: 1.5 } })
// Handle it in the reducer
function reducer(state, action) {
switch (action.type) {
case 'setCount':
return { ...state, count: action.payload }
case 'addItem':
return { ...state, items: [...state.items, action.payload] }
// ...
}
}Shopping Cart Example
Here is a realistic shopping cart that demonstrates why useReducer shines when you have multiple operations on the same state:
import { useReducer } from 'react'
const initialState = {
items: [], // { id, name, price, qty }
coupon: null,
}
function cartReducer(state, action) {
switch (action.type) {
case 'add_item': {
const exists = state.items.find(i => i.id === action.payload.id)
if (exists) {
// Increment quantity if already in cart
return {
...state,
items: state.items.map(i =>
i.id === action.payload.id ? { ...i, qty: i.qty + 1 } : i
),
}
}
return { ...state, items: [...state.items, { ...action.payload, qty: 1 }] }
}
case 'remove_item':
return {
...state,
items: state.items.filter(i => i.id !== action.payload),
}
case 'update_qty':
return {
...state,
items: state.items.map(i =>
i.id === action.payload.id ? { ...i, qty: action.payload.qty } : i
),
}
case 'apply_coupon':
return { ...state, coupon: action.payload }
case 'clear_cart':
return initialState
default:
throw new Error('Unknown cart action: ' + action.type)
}
}
export function ShoppingCart() {
const [cart, dispatch] = useReducer(cartReducer, initialState)
const total = cart.items.reduce((sum, i) => sum + i.price * i.qty, 0)
return (
<div>
<h2>Cart ({cart.items.length} items)</h2>
{cart.items.map(item => (
<div key={item.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<span>{item.name}</span>
<input
type="number"
value={item.qty}
min={1}
onChange={e =>
dispatch({
type: 'update_qty',
payload: { id: item.id, qty: Number(e.target.value) },
})
}
style={{ width: 48 }}
/>
<span>${(item.price * item.qty).toFixed(2)}</span>
<button onClick={() => dispatch({ type: 'remove_item', payload: item.id })}>
Remove
</button>
</div>
))}
<p>
<strong>Total: ${total.toFixed(2)}</strong>
</p>
<button onClick={() => dispatch({ type: 'clear_cart' })}>Clear Cart</button>
</div>
)
}Lazy Initialization
If computing the initial state is expensive — reading from localStorage, parsing a large structure — pass an init function as the third argument. React calls init(initialArg) once on mount instead of on every render:
function initCart(savedCart) {
// Called once — safe to do expensive work here
try {
return JSON.parse(savedCart) ?? { items: [], coupon: null }
} catch {
return { items: [], coupon: null }
}
}
// Pass the raw arg as second param; the init fn as third param
const [cart, dispatch] = useReducer(
cartReducer,
localStorage.getItem('cart'), // passed to initCart
initCart
)TypeScript: Typed Actions
Using a discriminated union for the action type makes the reducer fully type-safe and gives you autocomplete on payload:
type Item = { id: number; name: string; price: number; qty: number }
type CartState = { items: Item[]; coupon: string | null }
type CartAction =
| { type: 'add_item'; payload: Omit<Item, 'qty'> }
| { type: 'remove_item'; payload: number }
| { type: 'update_qty'; payload: { id: number; qty: number } }
| { type: 'apply_coupon'; payload: string }
| { type: 'clear_cart' }
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'add_item':
// TypeScript knows action.payload is Omit<Item, 'qty'> here
return { ...state, items: [...state.items, { ...action.payload, qty: 1 }] }
// ...
default:
return state
}
}When useReducer Beats useState
Signal | Why reducer helps |
|---|---|
State has 3+ related fields | One action updates multiple fields atomically |
Many different mutations are possible | Each mutation is a named action — easy to find in a switch |
Next state depends on previous state | Reducer always receives the latest state — no closure bugs |
State transitions need to be tested | Reducer is a pure function — test it directly without rendering |
Team members need to understand what can change | The action union serves as living documentation |