I used to handle all my state with useState, even when forms had 15+ fields and buttons triggered multiple state updates. The result? A tangled mess of setState calls that made debugging a nightmare. Then I discovered useReducer, and everything clicked.
useState is perfect for simple state: "toggle this boolean, increment this number."
But when your state involves:
- Objects with multiple interdependent properties
- Complex business logic that determines state changes
- State transitions that need to be predictable and testable
That's when useState becomes your enemy. You end up with scattered logic, hard-to-track bugs, and state updates that step on each other.
useReducer solves this by centralizing your state logic in one place, making it predictable and easier to reason about.
What is useReducer?
useReducer is React's built-in hook for managing complex state logic. It follows the same pattern as Redux but without the overhead of external libraries. Instead of directly setting state, you dispatch actions that describe what happened, and a reducer function determines how the state should change.
Think of it as having a dedicated state manager: "Here's what happened (action), you figure out what to do with it (reducer)."
useReducer Syntax and Structure
const [state, dispatch] = useReducer(reducer, initialState);
// The reducer function
const reducer = (currentState, action) => {
switch (action.type) {
case 'ACTION_TYPE':
return { ...currentState, /* updates */ };
default:
return currentState;
}
};
state– current state value (can be any type)dispatch– function to trigger state changesreducer– pure function that calculates the next stateinitialState– the starting state value
Real-World Example: Shopping Cart State
const initialState = {
items: [],
total: 0,
isLoading: false,
error: null
};
const cartReducer = (state, action) => {
switch (action.type) {
case 'ADD_ITEM':
const newItems = [...state.items, action.payload];
return {
...state,
items: newItems,
total: newItems.reduce((sum, item) => sum + item.price, 0)
};
case 'REMOVE_ITEM':
const filteredItems = state.items.filter(item => item.id !== action.payload);
return {
...state,
items: filteredItems,
total: filteredItems.reduce((sum, item) => sum + item.price, 0)
};
case 'SET_LOADING':
return { ...state, isLoading: action.payload };
case 'SET_ERROR':
return { ...state, error: action.payload, isLoading: false };
default:
return state;
}
};
const ShoppingCart = () => {
const [cart, dispatch] = useReducer(cartReducer, initialState);
const addItem = (item) => {
dispatch({ type: 'ADD_ITEM', payload: item });
};
const removeItem = (itemId) => {
dispatch({ type: 'REMOVE_ITEM', payload: itemId });
};
return (
<div>
<p>Total: $ {cart.total}</p>
<p>Items: {cart.items.length}</p>
{cart.error && <p className="error">{cart.error}</p>}
</div>
);
};
Notice how all the complex logic for calculating totals and managing different states is centralized in the reducer. This makes the component cleaner and the state changes predictable.
useState vs useReducer: Decision Framework
| Situation | Use useState |
Use useReducer |
|---|---|---|
| Simple toggle or counter | ✅ Perfect fit | ❌ Overkill |
| Form with 5+ fields | ❌ Gets messy | ✅ Much cleaner |
| State with business logic | ❌ Logic scattered | ✅ Centralized logic |
| Multiple related state updates | ❌ Race conditions | ✅ Atomic updates |
| Need to test state logic | ❌ Hard to isolate | ✅ Pure functions |
Production Pattern: Form State Management
const formReducer = (state, action) => {
switch (action.type) {
case 'SET_FIELD':
return {
...state,
values: {
...state.values,
[action.field]: action.value
},
errors: {
...state.errors,
[action.field]: null // Clear error when user types
}
};
case 'SET_ERRORS':
return {
...state,
errors: action.payload,
isSubmitting: false
};
case 'SET_SUBMITTING':
return {
...state,
isSubmitting: action.payload
};
case 'RESET_FORM':
return {
values: {},
errors: {},
isSubmitting: false
};
default:
return state;
}
};
const ContactForm = () => {
const [form, dispatch] = useReducer(formReducer, {
values: {},
errors: {},
isSubmitting: false
});
const handleChange = (field, value) => {
dispatch({ type: 'SET_FIELD', field, value });
};
const handleSubmit = async () => {
dispatch({ type: 'SET_SUBMITTING', payload: true });
try {
const response = await submitForm(form.values);
dispatch({ type: 'RESET_FORM' });
} catch (errors) {
dispatch({ type: 'SET_ERRORS', payload: errors });
}
};
return (
<div>
<input
type="email"
value={form.values.email || ''}
onChange={(e) => handleChange('email', e.target.value)}
/>
{form.errors.email && <span className="error">{form.errors.email}</span>}
<button onClick={handleSubmit} disabled={form.isSubmitting}>
{form.isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</div>
);
};
This pattern has saved me countless hours of debugging form state issues. All the form logic is in one place, making it easy to test and modify.
When to Choose useReducer
In my experience, useReducer shines when:
- State shape is complex – objects with multiple properties that change together
- State logic is complex – business rules that determine how state changes
- Related state updates – when one action needs to update multiple pieces of state
- Testability matters – reducer functions are pure and easy to unit test
- Team consistency – provides a standardized way to handle state across components
Avoid useReducer for:
- Simple boolean flags or counters
- Independent state values that don't interact
- Rapid prototyping where simplicity matters more than structure
Best Practices I've Learned
| Practice | Why It Matters |
|---|---|
| Keep reducers pure | No side effects, API calls, or mutations |
| Use action constants | Prevents typos and enables better IDE support |
| Always return new objects | Ensures React detects changes correctly |
| Structure actions consistently | Makes code predictable and maintainable |
| Handle default cases | Prevents unexpected state changes |
useReducer Quick Reference
| Purpose | Complex state management with predictable updates |
| API | [state, dispatch] = useReducer(reducer, initialState) |
| Reducer function | Pure function: (state, action) => newState |
| Triggers re-render | ✅ Yes, when state reference changes |
| Best for | Forms, state machines, complex business logic |
| Performance | Same as useState, but better for complex updates |
Frequently Asked Questions
Is useReducer always better than useState for complex state?
Not always. If your state is complex but the logic is simple (like just setting values), useState with objects can be cleaner. Choose useReducer when you have complex state transitions or business logic.
Can I use async operations in my reducer?
No, reducers must be pure functions. Handle async operations in useEffect or event handlers, then dispatch the results to your reducer.
How do I combine useReducer with Context for global state?
This is a powerful pattern I use often. Create a context that provides both state and dispatch, then components can access and update global state through the context.
Is useReducer similar to Redux?
Yes, the pattern is identical: actions describe what happened, reducers calculate the next state. useReducer is essentially Redux built into React, but for component-local state.
Should I use useReducer for all forms?
For simple forms with 2-3 fields, useState is fine. But once you have validation, error handling, and multiple steps, useReducer provides much better organization.