useReducer vs useState
React gives you two primary hooks for managing local component state. Choosing between them is a design decision, not a technical one — both can implement the same feature. The goal is to pick the approach that makes the code easier to read, test, and extend as requirements change.
The Core Difference
useStateis ideal for a single, independent value — a boolean, a string, a number, or a small object that changes as a whole.useReduceris ideal for structured state with multiple fields that are updated via clearly defined operations.
Same Feature, Two Implementations
Consider a to-do list. Here is the same feature implemented with each hook so you can compare the tradeoffs side by side.
With useState:
import { useState } from 'react'
function TodoList() {
const [todos, setTodos] = useState([])
const [input, setInput] = useState('')
function addTodo() {
if (!input.trim()) return
setTodos([...todos, { id: Date.now(), text: input, done: false }])
setInput('')
}
function toggleTodo(id) {
setTodos(todos.map(t => (t.id === id ? { ...t, done: !t.done } : t)))
}
function removeTodo(id) {
setTodos(todos.filter(t => t.id !== id))
}
return (
<div>
<input value={input} onChange={e => setInput(e.target.value)} />
<button onClick={addTodo}>Add</button>
{todos.map(todo => (
<div key={todo.id}>
<span
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
onClick={() => toggleTodo(todo.id)}
>
{todo.text}
</span>
<button onClick={() => removeTodo(todo.id)}>Delete</button>
</div>
))}
</div>
)
}With useReducer:
import { useReducer, useState } from 'react'
const initialState = []
function todosReducer(state, action) {
switch (action.type) {
case 'add':
return [...state, { id: Date.now(), text: action.payload, done: false }]
case 'toggle':
return state.map(t =>
t.id === action.payload ? { ...t, done: !t.done } : t
)
case 'remove':
return state.filter(t => t.id !== action.payload)
default:
throw new Error('Unknown action: ' + action.type)
}
}
function TodoList() {
const [todos, dispatch] = useReducer(todosReducer, initialState)
const [input, setInput] = useState('') // simple input value stays as useState
function addTodo() {
if (!input.trim()) return
dispatch({ type: 'add', payload: input })
setInput('')
}
return (
<div>
<input value={input} onChange={e => setInput(e.target.value)} />
<button onClick={addTodo}>Add</button>
{todos.map(todo => (
<div key={todo.id}>
<span
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
onClick={() => dispatch({ type: 'toggle', payload: todo.id })}
>
{todo.text}
</span>
<button onClick={() => dispatch({ type: 'remove', payload: todo.id })}>
Delete
</button>
</div>
))}
</div>
)
}Decision Framework
Factor | Lean useState | Lean useReducer |
|---|---|---|
State shape | Single value or small flat object | Nested object or multiple related fields |
Number of operations | 1–2 setters | 3+ distinct mutation types |
Transition logic | Simple assignment | Next state depends on previous state or action payload in complex ways |
Testing | Test via render | Test the pure reducer function directly |
Debugging | Add console.log to setter calls | Log every dispatched action; Redux DevTools integration |
Team readability | Familiar to everyone | Self-documenting via named action types |
The dispatch Stability Benefit
One underappreciated advantage of useReducer is that dispatch has a stable identity — it never changes between renders. Compare this to setter functions from useState, which are also stable, but the functions you create that call them are not:
// With useState — you create a NEW function on every render
function Parent() {
const [count, setCount] = useState(0)
// NEW function reference on every render
const increment = () => setCount(c => c + 1)
return <Child onIncrement={increment} /> // triggers re-render of Child every time
}
// With useReducer — dispatch itself is stable; pass it directly
function Parent() {
const [state, dispatch] = useReducer(reducer, { count: 0 })
// dispatch never changes — Child only re-renders when state changes
return <Child dispatch={dispatch} />
}Testing Pure Reducers
Because a reducer is a plain function with no side effects, you can test every state transition without mounting a React component. This is one of the biggest practical wins of the reducer pattern:
// todosReducer.test.js — no React, no render, no DOM
import { todosReducer } from './todosReducer'
describe('todosReducer', () => {
test('add action inserts a todo', () => {
const result = todosReducer([], { type: 'add', payload: 'Buy milk' })
expect(result).toHaveLength(1)
expect(result[0].text).toBe('Buy milk')
expect(result[0].done).toBe(false)
})
test('toggle action flips done flag', () => {
const initial = [{ id: 1, text: 'Buy milk', done: false }]
const result = todosReducer(initial, { type: 'toggle', payload: 1 })
expect(result[0].done).toBe(true)
})
test('remove action deletes the correct todo', () => {
const initial = [
{ id: 1, text: 'Buy milk', done: false },
{ id: 2, text: 'Walk dog', done: false },
]
const result = todosReducer(initial, { type: 'remove', payload: 1 })
expect(result).toHaveLength(1)
expect(result[0].id).toBe(2)
})
})Combining Both Hooks
You do not have to choose one or the other for the entire component. A common and idiomatic pattern is to use useReducer for the complex domain state and useState for simple UI state:
function TodoApp() {
// Complex domain state — reducer
const [todos, dispatch] = useReducer(todosReducer, [])
// Simple UI state — useState
const [inputValue, setInputValue] = useState('')
const [filterMode, setFilterMode] = useState('all') // 'all' | 'active' | 'done'
const visible = todos.filter(t => {
if (filterMode === 'active') return !t.done
if (filterMode === 'done') return t.done
return true
})
// ...
}Context + useReducer: Redux-Lite
Pairing useReducer with useContext gives you a lightweight global state system without any external library — sometimes called "Redux-lite":
const StoreContext = createContext(null)
function StoreProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState)
// Pass both state and dispatch through context
return (
<StoreContext.Provider value={{ state, dispatch }}>
{children}
</StoreContext.Provider>
)
}
function useStore() {
const ctx = useContext(StoreContext)
if (!ctx) throw new Error('useStore must be inside StoreProvider')
return ctx
}
// Any component can read state and dispatch actions
function ProductCard({ product }) {
const { dispatch } = useStore()
return (
<button
onClick={() => dispatch({ type: 'add_to_cart', payload: product })}
>
Add to cart
</button>
)
}