TL;DR: useReducer is React's built-in tool for managing complex state. Instead of multiple useState calls that interact with each other, you define one reducer function that handles all state changes in one place. Think of it as a switchboard: events come in, the reducer decides how state changes. AI reaches for it when your component state gets too tangled for useState to handle cleanly.
Why AI Coders Need This
Here's what happens: you start with a simple component. A form with two fields. useState for each. Easy. Then you add validation. Then loading states. Then error handling. Then undo/redo. Suddenly you have seven useState calls that all depend on each other, and updating one means updating three others.
This is when AI switches to useReducer. Not because it's showing off — because your state got complex enough that useState becomes a maintenance nightmare. Understanding why saves you from reverting AI's improvements back to a worse pattern.
If you've built apps with Cursor or Claude Code, you've seen this happen. The AI refactors your state, the code looks different, but you're not sure if it's better or just more verbose. By the end of this article, you'll know.
The Problem: When useState Falls Apart
Look at this shopping cart component. Seems fine at first:
// The useState version — starts clean, gets messy fast
function ShoppingCart() {
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [itemCount, setItemCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [discount, setDiscount] = useState(0);
const [lastAction, setLastAction] = useState(null); // for undo
const addItem = (product) => {
setItems(prev => [...prev, product]);
setTotal(prev => prev + product.price);
setItemCount(prev => prev + 1);
setLastAction({ type: 'add', product });
setError(null);
// What if setTotal fails but setItems succeeds?
// Now your cart and total are out of sync.
};
const removeItem = (id) => {
const item = items.find(i => i.id === id);
setItems(prev => prev.filter(i => i.id !== id));
setTotal(prev => prev - item.price);
setItemCount(prev => prev - 1);
setLastAction({ type: 'remove', product: item });
// Same sync problem. Six setter calls for one action.
};
const applyDiscount = (code) => {
setIsLoading(true);
setError(null);
// ...fetch discount from API...
// On success: setDiscount(amount), setTotal(recalculate), setIsLoading(false)
// On error: setError(msg), setIsLoading(false)
// That's 3-4 more setter calls per code path
};
}
See the problem? Every action requires updating 3-6 pieces of state simultaneously. If any update fails or happens out of order, your state becomes inconsistent — the item count says 3 but there are 4 items, or the total doesn't match the actual prices.
This is not a skill issue. This is a structural problem with using useState for interconnected state.
The useReducer Solution
Here's the same cart with useReducer:
// The useReducer version — all state changes in one place
const initialState = {
items: [],
total: 0,
itemCount: 0,
isLoading: false,
error: null,
discount: 0,
lastAction: null,
};
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const newItems = [...state.items, action.product];
return {
...state,
items: newItems,
total: state.total + action.product.price,
itemCount: state.itemCount + 1,
lastAction: { type: 'add', product: action.product },
error: null,
};
}
case 'REMOVE_ITEM': {
const item = state.items.find(i => i.id === action.id);
return {
...state,
items: state.items.filter(i => i.id !== action.id),
total: state.total - item.price,
itemCount: state.itemCount - 1,
lastAction: { type: 'remove', product: item },
};
}
case 'APPLY_DISCOUNT':
return {
...state,
discount: action.amount,
total: state.total - action.amount,
isLoading: false,
};
case 'SET_LOADING':
return { ...state, isLoading: true, error: null };
case 'SET_ERROR':
return { ...state, error: action.message, isLoading: false };
case 'UNDO': {
// Because we track lastAction, undo is straightforward
if (!state.lastAction) return state;
if (state.lastAction.type === 'add') {
return cartReducer(state, {
type: 'REMOVE_ITEM',
id: state.lastAction.product.id
});
}
// ... handle other undo cases
return state;
}
default:
return state;
}
}
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, initialState);
const addItem = (product) =>
dispatch({ type: 'ADD_ITEM', product });
const removeItem = (id) =>
dispatch({ type: 'REMOVE_ITEM', id });
const undo = () =>
dispatch({ type: 'UNDO' });
// Clean. Every action is one dispatch call.
// State changes are atomic — no sync bugs.
}
Understanding Each Part
The Three Pieces
useReducer has three parts, and once you see the pattern, you see it everywhere:
- State — one object holding all your related data (items, total, loading, error — everything)
- Actions — plain objects describing what happened:
{ type: 'ADD_ITEM', product } - Reducer — a pure function that takes the current state + an action and returns new state
The mental model: State + Action = New State. That's it. Every state change flows through one function. No more wondering which of six setters is causing a bug.
Why "dispatch" Instead of "setState"?
With useState, you tell React what the new value should be: setCount(5). With useReducer, you tell React what happened: dispatch({ type: 'INCREMENT' }). The reducer decides what the new value should be.
This is a subtle but powerful difference. Your component says "the user added an item" and the reducer handles all the consequences — updating the items array, recalculating the total, incrementing the count, clearing errors. The component doesn't need to know about any of that complexity.
The switch Statement
The switch in the reducer is just a way to handle different action types. It's not magic — you could use if/else chains instead. AI prefers switch because it's the convention and makes action types scannable at a glance.
Every case returns { ...state, changed_field: new_value }. The spread operator (...) copies all existing state, then overwrites only the fields that changed. This is how you update one thing without losing everything else. If you see this pattern and don't understand it, read What Is the Spread Operator? first.
useState vs useReducer: When to Use Each
| Scenario | Use useState | Use useReducer |
|---|---|---|
| Toggle (dark mode, sidebar) | ✅ | ❌ Overkill |
| Single text input | ✅ | ❌ Overkill |
| Counter | ✅ | ❌ Overkill |
| Form with validation | ⚠️ Gets messy | ✅ |
| Shopping cart | ❌ Sync bugs | ✅ |
| Multi-step wizard | ❌ Too many states | ✅ |
| Undo/redo support | ❌ Very hard | ✅ Natural fit |
| State shared via Context | ⚠️ Re-render issues | ✅ Better performance |
The simple rule: If you have 1-2 independent state values, use useState. If you have 3+ state values that change together, or if the next state depends on the current state in complex ways, use useReducer.
What AI Gets Wrong About useReducer
AI loves useReducer and sometimes uses it for a simple boolean toggle or a single counter. If your reducer has two cases and one state field, it should be useState. Tell AI: "This is too simple for a reducer — use useState instead."
Reducers must return new state objects, never modify the existing one. AI sometimes writes state.items.push(item); return state; — this mutates the original and causes React to skip re-renders because it thinks nothing changed. The fix: always use spread (...state) and array methods that return new arrays (.filter(), .map(), [...arr, item]).
Reducers must be pure functions — no side effects, no API calls, no localStorage. AI sometimes puts fetch() calls inside a reducer case. This breaks React's expectations and causes unpredictable bugs. API calls belong in event handlers or useEffect; the reducer only handles the state changes that result from those calls.
AI occasionally generates a switch statement without a default case that returns the current state. Without it, dispatching an unknown action type returns undefined, which crashes your app. Always check that the reducer has default: return state; at the end.
useReducer + useContext: The "Poor Man's Redux"
One of the most powerful patterns AI generates is combining useReducer with useContext to share state across your entire app without installing any extra library:
// Create a context with your reducer
const CartContext = createContext(null);
const CartDispatchContext = createContext(null);
function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialState);
return (
<CartContext.Provider value={state}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartContext.Provider>
);
}
// Any component anywhere in your app can now:
function AddToCartButton({ product }) {
const dispatch = useContext(CartDispatchContext);
return (
<button onClick={() => dispatch({ type: 'ADD_ITEM', product })}>
Add to Cart
</button>
);
}
function CartTotal() {
const { total, itemCount } = useContext(CartContext);
return <span>{itemCount} items — ${total}</span>;
}
This pattern handles 90% of the state management needs vibe coders encounter. You don't need Zustand, Redux, or Jotai unless your app has truly global, performance-critical state. When AI suggests those libraries, ask: "Can this be done with useReducer + useContext instead?"
Debugging useReducer with AI
When your reducer isn't working right, give your AI this template:
"Here's my reducer: [paste reducer]. When I dispatch [action], I expect [expected state], but I get [actual state]. What's wrong?"
Because reducers are pure functions (same input = same output), they're actually easier to debug than useState spaghetti. You can even test them without React:
// Test your reducer directly — no component needed
const result = cartReducer(
{ items: [], total: 0, itemCount: 0 },
{ type: 'ADD_ITEM', product: { id: '1', name: 'Widget', price: 9.99 } }
);
console.log(result);
// { items: [{ id: '1', ... }], total: 9.99, itemCount: 1 }
Frequently Asked Questions
Use useReducer when your state has multiple related values that change together, when the next state depends on the previous state, or when you have more than 3-4 useState calls in one component that interact with each other. For a single toggle or text input, useState is simpler.
Yes — they share the same pattern. Redux popularized the reducer pattern; React later built useReducer directly into the framework. For most apps built by vibe coders, useReducer with useContext is enough — you don't need Redux at all.
dispatch is the function useReducer gives you to trigger state changes. Instead of setting state directly, you dispatch an action object describing what happened: dispatch({ type: 'ADD_ITEM', product }). The reducer then decides how state should change. Think of it as sending a message: "this happened" — the reducer decides what to do about it.
Yes, and it's a great combination. TypeScript can type your state shape and action types, giving you autocomplete and catching invalid dispatches at compile time. Ask AI to "use discriminated union types for the actions" for the best experience.
AI tends to over-engineer when it anticipates complexity. A simple counter doesn't need useReducer. If the reducer has two cases and one state field, tell AI: "Simplify this to useState — it's not complex enough for a reducer."