useReducer vs useState
When useState Becomes Painful
useState works for simple, independent state values. But when state logic grows — multiple related values, complex transitions, validation rules — useState handlers become tangled:
// useState pain: related state with complex transitions
function ShoppingCart() {
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [discount, setDiscount] = useState(0);
const [promoApplied, setPromoApplied] = useState(false);
function addItem(item) {
setItems(prev => [...prev, item]);
setTotal(prev => prev + item.price);
// Bug: what if discount needs recalculating?
// Bug: what if promoApplied affects the total differently?
}
function removeItem(itemId) {
setItems(prev => {
const item = prev.find(i => i.id === itemId);
setTotal(t => t - item.price); // Nested setState — fragile
return prev.filter(i => i.id !== itemId);
});
}
// Every handler must coordinate 4 state values correctly.
// One mistake and state becomes inconsistent.
}
Think of useState as a light switch — on or off, one value changes. useReducer is a control panel with labeled buttons. Each button (action) triggers a predefined sequence of changes. The control panel (reducer) guarantees that pressing "ADD_ITEM" always produces a consistent state, no matter who pressed it or when. You cannot accidentally update total without updating items — the reducer handles both atomically.
useReducer: Centralized State Logic
const initialState = {
items: [],
total: 0,
discount: 0,
promoApplied: false,
};
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const newItems = [...state.items, action.item];
const newTotal = newItems.reduce((sum, i) => sum + i.price, 0);
return {
...state,
items: newItems,
total: state.promoApplied ? newTotal * (1 - state.discount) : newTotal,
};
}
case 'REMOVE_ITEM': {
const newItems = state.items.filter(i => i.id !== action.itemId);
const newTotal = newItems.reduce((sum, i) => sum + i.price, 0);
return {
...state,
items: newItems,
total: state.promoApplied ? newTotal * (1 - state.discount) : newTotal,
};
}
case 'APPLY_PROMO': {
const rawTotal = state.items.reduce((sum, i) => sum + i.price, 0);
return {
...state,
promoApplied: true,
discount: action.discount,
total: rawTotal * (1 - action.discount),
};
}
default:
return state;
}
}
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, initialState);
return (
<div>
<button onClick={() => dispatch({ type: 'ADD_ITEM', item: newItem })}>
Add Item
</button>
<p>Total: ${state.total}</p>
</div>
);
}
Every state transition is defined in one place. The reducer is a pure function — easy to test, easy to reason about, impossible to accidentally skip a related update.
dispatch Is Referentially Stable
Here's a hidden gem that most tutorials skip over: dispatch has a stable identity across renders. It never changes:
function Parent() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
// dispatch reference never changes — Child never re-renders due to prop change
<MemoizedChild onAction={dispatch} />
);
}
const MemoizedChild = React.memo(function Child({ onAction }) {
console.log('Child rendered');
return <button onClick={() => onAction({ type: 'INCREMENT' })}>+</button>;
});
With useState, you would need useCallback to prevent the handler from changing every render:
// useState requires useCallback for stable handlers
const handleIncrement = useCallback(() => {
setCount(prev => prev + 1);
}, []);
With useReducer, dispatch is automatically stable. No useCallback needed.
Why dispatch is stable
React creates the dispatch function once during mount and binds it to the Fiber and queue. It is stored on the hook's queue object and never recreated. This is different from setState — while setState is also stable, complex handlers built on top of setState typically create new closures each render. dispatch passes the logic to the reducer, keeping the callback simple and stable.
Reducer as State Machine
This is where reducers really shine. For complex UI flows, you can model your state as a finite state machine:
function reducer(state, action) {
switch (state.status) {
case 'idle': {
if (action.type === 'FETCH') {
return { ...state, status: 'loading' };
}
return state;
}
case 'loading': {
if (action.type === 'SUCCESS') {
return { status: 'success', data: action.data, error: null };
}
if (action.type === 'ERROR') {
return { status: 'error', data: null, error: action.error };
}
return state; // Ignore other actions during loading
}
case 'success': {
if (action.type === 'FETCH') {
return { ...state, status: 'loading' };
}
return state;
}
case 'error': {
if (action.type === 'FETCH') {
return { ...state, status: 'loading', error: null };
}
return state;
}
default:
return state;
}
}
This pattern prevents impossible states. You cannot have status: 'loading' with data: someValue or dispatch SUCCESS while idle. The state machine enforces valid transitions.
When to Use Each
| Scenario | useState | useReducer |
|---|---|---|
| Single boolean/string/number | Best | Overkill |
| Two independent values | Good | Fine |
| Multiple related values that update together | Awkward | Best |
| Complex state transitions with rules | Fragile | Best |
| State machine with defined transitions | Poor | Best |
| Passing update function to memoized children | Needs useCallback | dispatch is stable |
| Simple toggle | Best | Overkill |
Do not use useReducer for everything. A simple boolean toggle (const [isOpen, setIsOpen] = useState(false)) is clearer with useState. useReducer adds indirection — the action type, the switch statement, the dispatch call. This indirection pays off when state logic is complex, but it is overhead when state logic is trivial.
Production Scenario: Form State Machine
const formReducer = (state, action) => {
switch (action.type) {
case 'FIELD_CHANGE':
return {
...state,
values: { ...state.values, [action.field]: action.value },
errors: { ...state.errors, [action.field]: '' }, // Clear error on change
touched: { ...state.touched, [action.field]: true },
};
case 'FIELD_BLUR':
return {
...state,
touched: { ...state.touched, [action.field]: true },
errors: {
...state.errors,
[action.field]: validateField(action.field, state.values[action.field]),
},
};
case 'SUBMIT_START':
return { ...state, submitting: true, submitError: null };
case 'SUBMIT_SUCCESS':
return { ...state, submitting: false, submitted: true };
case 'SUBMIT_ERROR':
return { ...state, submitting: false, submitError: action.error };
case 'RESET':
return initialFormState;
default:
return state;
}
};
function RegistrationForm() {
const [form, dispatch] = useReducer(formReducer, initialFormState);
return (
<form onSubmit={e => {
e.preventDefault();
dispatch({ type: 'SUBMIT_START' });
submitForm(form.values)
.then(() => dispatch({ type: 'SUBMIT_SUCCESS' }))
.catch(err => dispatch({ type: 'SUBMIT_ERROR', error: err.message }));
}}>
<input
value={form.values.email}
onChange={e => dispatch({
type: 'FIELD_CHANGE', field: 'email', value: e.target.value
})}
onBlur={() => dispatch({ type: 'FIELD_BLUR', field: 'email' })}
/>
{form.touched.email && form.errors.email && (
<span>{form.errors.email}</span>
)}
<button disabled={form.submitting}>
{form.submitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
| What developers do | What they should do |
|---|---|
| Using useReducer for a simple boolean toggle useReducer adds indirection (action types, switch cases) that is not justified for trivial state. A simple setIsOpen(prev => !prev) is clearer. | Use useState for simple, independent values |
| Mutating state in the reducer: state.items.push(item); return state Reducers must be pure. Returning the same reference after mutation causes Object.is to return true → no re-render. The mutation is invisible. | Always return a new object: return { ...state, items: [...state.items, item] } |
| Using useCallback around dispatch: useCallback((item) => dispatch(`{type: 'ADD', item}`), [dispatch]) dispatch never changes. Wrapping it in useCallback is unnecessary overhead. Pass it directly to memoized children. | Pass dispatch directly — it is already referentially stable |
| Putting async logic inside the reducer Reducers must be synchronous pure functions. Dispatch the action before the async call (LOADING), then dispatch again with the result (SUCCESS/ERROR). | Keep reducers pure — handle async in event handlers or effects, dispatch results |
- 1useReducer centralizes state transitions — all update logic in one pure function
- 2dispatch is referentially stable — no useCallback needed when passing to children
- 3Reducers must be pure: no mutations, no side effects, always return new objects for state changes
- 4Use state machines in reducers to prevent impossible states
- 5Choose useReducer when state values are related and must update together consistently
Challenge: Convert useState to useReducer
Challenge: Reducer Migration
// Convert this useState-based component to useReducer.
// Ensure all state transitions are handled atomically.
function TodoApp() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');
const [nextId, setNextId] = useState(1);
function addTodo(text) {
setTodos(prev => [...prev, { id: nextId, text, completed: false }]);
setNextId(prev => prev + 1);
}
function toggleTodo(id) {
setTodos(prev =>
prev.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
);
}
function clearCompleted() {
setTodos(prev => prev.filter(t => !t.completed));
}
}
Show Answer
const initialState = {
todos: [],
filter: 'all',
nextId: 1,
};
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, {
id: state.nextId,
text: action.text,
completed: false,
}],
nextId: state.nextId + 1,
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(t =>
t.id === action.id ? { ...t, completed: !t.completed } : t
),
};
case 'CLEAR_COMPLETED':
return {
...state,
todos: state.todos.filter(t => !t.completed),
};
case 'SET_FILTER':
return { ...state, filter: action.filter };
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialState);
const { todos, filter } = state;
// addTodo is now atomic — todos and nextId update together
// dispatch is stable — safe to pass to memoized children
}Key improvements:
ADD_TODOatomically adds the item and increments nextId — impossible to have them out of sync- The reducer is a pure function — easy to unit test without rendering a component
dispatchis referentially stable — no useCallback needed for child components