When useState Becomes a Trap: Why useReducer Is the Better Choice
This article explains why overusing useState in complex React components can lead to tangled code and bugs, and demonstrates how switching to useReducer provides a single source of truth, clearer actions, and more maintainable state management.
useState is the most direct state manager and is loved for its simplicity and speed, but blindly applying it to every piece of state can actually make your code more complex.
I used to write const [something, setSomething] = useState() without thinking, until a late‑night debugging session forced me to embrace useReducer .
useState Pitfall
Imagine a user dashboard with filters, sorting, and pagination. The component starts out looking simple:
<code>function UserDashboard() {
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [selectedFilters, setSelectedFilters] = useState({});
const [currentPage, setCurrentPage] = useState(1);
const [sortBy, setSortBy] = useState('name');
const [sortOrder, setSortOrder] = useState('asc');
const fetchUsers = async () => {
setIsLoading(true);
setError(null);
try {
const response = await api.getUsers({
filters: selectedFilters,
page: currentPage,
sortBy,
sortOrder
});
setUsers(response.data);
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
}</code>It looks innocent, but problems quickly multiply: resetting pagination when filters change, deciding whether sorting should trigger loading, and coordinating multiple state updates become a mental juggling act.
When another developer asks why changing a filter updates the page but changing the sort does not, hours can be spent digging through scattered setState calls.
Enter useReducer
At this point, useReducer steps in like a superhero with a predictable state‑transition cape.
Instead of scattering state updates across the component, useReducer gives you:
A single source of truth (a state object).
A “recipe book” for state changes (your reducer function).
Clear, intention‑revealing actions (what happened, not how the state was mutated).
Here’s the same dashboard refactored with useReducer :
<code>const initialState = {
users: [],
isLoading: false,
error: null,
filters: {},
pagination: { currentPage: 1, itemsPerPage: 10 },
sorting: { field: 'name', order: 'asc' }
};
function dashboardReducer(state, action) {
switch (action.type) {
case 'FETCH_USERS_START':
return { ...state, isLoading: true, error: null };
case 'FETCH_USERS_SUCCESS':
return { ...state, isLoading: false, users: action.payload };
case 'FETCH_USERS_ERROR':
return { ...state, isLoading: false, error: action.payload };
case 'UPDATE_FILTERS':
return { ...state, filters: action.payload, pagination: { ...state.pagination, currentPage: 1 } };
case 'CHANGE_PAGE':
return { ...state, pagination: { ...state.pagination, currentPage: action.payload } };
case 'CHANGE_SORT':
return { ...state, sorting: action.payload };
default:
return state;
}
}
function UserDashboard() {
const [state, dispatch] = useReducer(dashboardReducer, initialState);
const { users, isLoading, error, filters, pagination, sorting } = state;
const fetchUsers = async () => {
dispatch({ type: 'FETCH_USERS_START' });
try {
const response = await api.getUsers({
filters,
page: pagination.currentPage,
sortBy: sorting.field,
sortOrder: sorting.order
});
dispatch({ type: 'FETCH_USERS_SUCCESS', payload: response.data });
} catch (err) {
dispatch({ type: 'FETCH_USERS_ERROR', payload: err.message });
}
};
const handleFilterChange = newFilters => {
dispatch({ type: 'UPDATE_FILTERS', payload: newFilters });
};
}</code>Now state transitions are predictable, centralized, and self‑documenting.
useReducer Use Cases
Don’t throw useState away completely—it’s still perfect for simple, independent variables like a modal toggle ( useState(false) ).
Upgrade to useReducer when you notice any of these signs:
1. Complex state relationships
If changing one slice should automatically affect others (e.g., filters resetting pagination), useReducer makes those relationships explicit.
2. Need to update multiple states together
When you find yourself calling setCurrentPage(1) and setIsLoading(false) separately, a single reducer action guarantees atomic updates.
<code>const handleSearch = async searchTerm => {
dispatch({ type: 'SEARCH_START', payload: searchTerm });
try {
const data = await fetchSearchResults(searchTerm);
dispatch({ type: 'SEARCH_SUCCESS', payload: data });
} catch (err) {
dispatch({ type: 'SEARCH_ERROR', payload: err.message });
}
};</code>3. Tired of “find‑the‑bug” games
With useReducer , every state change has a named action, making it easy to trace the exact sequence that led to a bug.
How useReducer Saved Our Team’s Sanity
We once had a massive form wizard built entirely with dozens of useState calls. Refactoring it to a single useReducer gave us:
Faster onboarding—reading the reducer tells you the whole form logic.
Confidence in fixing bugs—knowing exactly which action caused the issue.
Fearless feature addition—just add a new action type.
Recommendations
If you’re convinced to try useReducer , consider these practical tips:
1. Don’t rewrite everything at once
Start with the most painful component and mix both hooks if needed:
<code>function HybridComponent() {
const [isExpanded, setIsExpanded] = useState(false);
const [formState, dispatch] = useReducer(formReducer, initialFormState);
}</code>2. Name actions descriptively
Actions should read like logs of what the user or system did:
<code>'USER_SELECTED'
'FILTER_APPLIED'
'FORM_SUBMITTED'
'API_REQUEST_FAILED'
'SET_STATE'
'UPDATE_DATA'
'CHANGE_VALUES'</code>3. Use TypeScript
Typed state and actions catch errors before they run:
<code>type State = {
users: User[];
isLoading: boolean;
error: string | null;
};
type Action =
| { type: 'FETCH_USERS_START' }
| { type: 'FETCH_USERS_SUCCESS'; payload: User[] }
| { type: 'FETCH_USERS_ERROR'; payload: string }
| { type: 'UPDATE_FILTERS'; payload: Record<string, any> };
function reducer(state: State, action: Action): State {
// reducer implementation
}
</code>4. Build reusable reducer utilities
Once comfortable, extract patterns like pagination into reusable reducers:
<code>function createPaginationReducer(itemsPerPage = 10) {
return (state, action) => {
switch (action.type) {
case 'NEXT_PAGE':
return { ...state, currentPage: state.currentPage + 1 };
case 'PREV_PAGE':
return { ...state, currentPage: Math.max(1, state.currentPage - 1) };
case 'GO_TO_PAGE':
return { ...state, currentPage: action.payload };
case 'SET_ITEMS_PER_PAGE':
return { ...state, itemsPerPage: action.payload, currentPage: 1 };
default:
return state;
}
};
}
</code>Final Summary
useState is an elegant API for simple state needs, but as your component grows, useReducer is like upgrading from a bicycle to a car—both get you there, but the car is built for longer, more complex journeys.
Next time you reach for another useState in a component that already manages multiple pieces of state, pause and ask yourself if you’re adding unnecessary complexity; if the answer is even a little “yes,” give useReducer a try.
Code Mala Tang
Read source code together, write articles together, and enjoy spicy hot pot together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.