React useReducer: Complex State Management Patterns (2026)
useReducer is React's built-in alternative to useState for managing complex state logic. Rather than scattered setState calls, you describe all state transitions as explicit actions handled in a single reducer function. This makes state changes predictable, testable and easy to reason about — especially when multiple pieces of state must update together.
Table of Contents
useReducer Basics
import { useReducer } from 'react'
// State shape
const initialState = { count: 0, step: 1 }
// Pure reducer function — no side effects
function reducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + state.step }
case 'DECREMENT':
return { ...state, count: state.count - state.step }
case 'SET_STEP':
return { ...state, step: action.payload }
case 'RESET':
return initialState
default:
throw new Error(`Unknown action: ${action.type}`)
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState)
return (
<div>
<p>Count: {state.count} (step: {state.step})</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
<input
type="number"
value={state.step}
onChange={e => dispatch({ type: 'SET_STEP', payload: +e.target.value })}
/>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
</div>
)
}
useState vs useReducer
Choose useReducer when:
- Multiple state values update together in response to the same event
- Next state depends on previous state in complex ways
- You need to pass dispatch (not state setters) to deeply nested components
- You want to test state logic in isolation
- State transitions need to be auditable (logging, debugging)
Stick with useState for simple, independent pieces of state (a toggle, an input value, a loading flag).
TypeScript Patterns
// Discriminated union for actions — exhaustiveness checked by TypeScript
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET_STEP'; payload: number }
| { type: 'RESET' }
interface CounterState {
count: number
step: number
}
function reducer(state: CounterState, action: Action): CounterState {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + state.step }
case 'DECREMENT':
return { ...state, count: state.count - state.step }
case 'SET_STEP':
return { ...state, step: action.payload }
case 'RESET':
return initialState
// TypeScript error if you forget a case — exhaustive check
}
}
// Action creator helpers
const actions = {
increment: (): Action => ({ type: 'INCREMENT' }),
setStep: (step: number): Action => ({ type: 'SET_STEP', payload: step }),
}
Combining with Context
The classic pattern for lightweight global state: useReducer for logic, Context for distribution:
// store/cart.tsx
import { createContext, useContext, useReducer, ReactNode } from 'react'
interface CartItem { id: string; name: string; qty: number; price: number }
interface CartState { items: CartItem[]; isOpen: boolean }
type CartAction =
| { type: 'ADD_ITEM'; payload: CartItem }
| { type: 'REMOVE_ITEM'; payload: string }
| { type: 'UPDATE_QTY'; payload: { id: string; qty: number } }
| { type: 'TOGGLE_CART' }
| { type: 'CLEAR' }
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find(i => i.id === action.payload.id)
if (existing) {
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] }
}
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 'TOGGLE_CART':
return { ...state, isOpen: !state.isOpen }
case 'CLEAR':
return { items: [], isOpen: false }
default:
return state
}
}
const CartContext = createContext<{
state: CartState
dispatch: React.Dispatch<CartAction>
} | null>(null)
export function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [], isOpen: false })
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
)
}
export function useCart() {
const ctx = useContext(CartContext)
if (!ctx) throw new Error('useCart must be inside CartProvider')
return ctx
}
// Usage
function AddToCartButton({ product }) {
const { dispatch } = useCart()
return (
<button onClick={() => dispatch({ type: 'ADD_ITEM', payload: product })}>
Add to Cart
</button>
)
}
Immer Integration
Use Immer to write mutating-style reducers without actually mutating:
import { useReducer } from 'react'
import { produce } from 'immer'
// With Immer — write mutations directly, Immer handles immutability
const cartReducer = produce((draft, action) => {
switch (action.type) {
case 'ADD_ITEM': {
const item = draft.items.find(i => i.id === action.payload.id)
if (item) {
item.qty += 1 // Direct mutation — Immer handles the copy
} else {
draft.items.push(action.payload)
}
break
}
case 'REMOVE_ITEM':
draft.items = draft.items.filter(i => i.id !== action.payload)
break
}
})
function useCart() {
return useReducer(cartReducer, { items: [], isOpen: false })
}
Async Actions
Reducers must be pure and synchronous. Handle async logic outside the reducer:
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string }
type FetchAction<T> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: T }
| { type: 'FETCH_ERROR'; payload: string }
function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
switch (action.type) {
case 'FETCH_START': return { status: 'loading' }
case 'FETCH_SUCCESS': return { status: 'success', data: action.payload }
case 'FETCH_ERROR': return { status: 'error', error: action.payload }
default: return state
}
}
function useAsyncData<T>(url: string) {
const [state, dispatch] = useReducer(fetchReducer<T>, { status: 'idle' })
useEffect(() => {
dispatch({ type: 'FETCH_START' })
fetch(url)
.then(r => r.json())
.then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
.catch(err => dispatch({ type: 'FETCH_ERROR', payload: err.message }))
}, [url])
return state
}
Testing Reducers
Pure functions are trivial to test — no mocking required:
import { cartReducer } from './cart'
describe('cartReducer', () => {
const emptyState = { items: [], isOpen: false }
test('ADD_ITEM adds a new item', () => {
const action = { type: 'ADD_ITEM', payload: { id: '1', name: 'Widget', qty: 1, price: 9.99 } }
const next = cartReducer(emptyState, action)
expect(next.items).toHaveLength(1)
expect(next.items[0].name).toBe('Widget')
})
test('ADD_ITEM increments qty for existing item', () => {
const state = { items: [{ id: '1', name: 'Widget', qty: 1, price: 9.99 }], isOpen: false }
const next = cartReducer(state, { type: 'ADD_ITEM', payload: { id: '1', name: 'Widget', qty: 1, price: 9.99 } })
expect(next.items[0].qty).toBe(2)
})
test('CLEAR empties the cart', () => {
const state = { items: [{ id: '1', name: 'Widget', qty: 2, price: 9.99 }], isOpen: true }
const next = cartReducer(state, { type: 'CLEAR' })
expect(next.items).toHaveLength(0)
expect(next.isOpen).toBe(false)
})
})
Advanced Patterns
Lazy initializer — compute expensive initial state only once:
function init(initialCount) {
// Expensive computation runs only on mount
return { count: initialCount, history: [] }
}
function Counter({ initialCount = 0 }) {
// init function called once with initialCount as argument
const [state, dispatch] = useReducer(reducer, initialCount, init)
}
Middleware pattern — wrap dispatch for logging/analytics:
function useReducerWithLogger(reducer, initialState) {
const [state, dispatch] = useReducer(reducer, initialState)
const loggedDispatch = useCallback((action) => {
console.log('Dispatching:', action)
console.log('Previous state:', state)
dispatch(action)
// Note: state here is still previous — log next state in reducer
}, [dispatch, state])
return [state, loggedDispatch]
}