When I first started using useState
in my components, it felt like a natural and clean way to manage state. It was straightforward—ideal for toggles, counters, inputs. But as my components grew in complexity, juggling multiple state variables and updates quickly became a mess. That’s when I realized useReducer
wasn’t just a more advanced tool—it was often the better choice for cleaner, more maintainable state logic.
Let me walk you through how I transitioned from useState
to useReducer
and how it fundamentally improved the way I manage state in more dynamic components.
When useState Falls Short
While building a form with five fields—name, email, password, confirm password, and newsletter opt-in—I initially reached for useState
and created separate state variables for each input. It worked, but as soon as I introduced validation logic and submission states like isSubmitting
, errors
, and touched
, the state management turned into a scattered patchwork of variables and handlers.
It wasn’t just about having too many useState
hooks—it was the way they grew disconnected. Updating them meant writing multiple handlers and remembering which state controlled what. This redundancy started affecting performance and readability. I wanted a centralized way to manage state transitions with clarity. That’s when I pivoted to useReducer
.
Rewriting State Logic
The first thing I did was define an initial state object. Instead of having separate hooks, I now had one object that held everything:
const initialState = {
name: '',
email: '',
password: '',
confirmPassword: '',
newsletter: false,
isSubmitting: false,
errors: {},
};
Next, I created a reducer function. This function became the core of my state updates, eliminating the need for multiple setState
calls spread throughout the component.
function reducer(state, action) {
switch (action.type) {
case 'UPDATE_FIELD':
return { ...state, [action.field]: action.value };
case 'SET_ERRORS':
return { ...state, errors: action.errors };
case 'SUBMIT_START':
return { ...state, isSubmitting: true };
case 'SUBMIT_END':
return { ...state, isSubmitting: false };
default:
return state;
}
}
Instead of individual handlers for each field, I now had one dynamic dispatch function:
const handleChange = (e) => {
dispatch({ type: 'UPDATE_FIELD', field: e.target.name, value: e.target.value });
};
It was a joy to see how much cleaner the code looked. Everything related to state was handled in a single, predictable place. Even better, my state logic was now decoupled from the component’s rendering logic, making it easier to refactor or test.
Cleaner Submission Flow
When handling form submissions, I used to update three or four states in a single function—like setting loading, clearing errors, and resetting fields. With useReducer
, it was simplified into clear dispatches:
const handleSubmit = async (e) => {
e.preventDefault();
dispatch({ type: 'SUBMIT_START' });
const errors = validate(state);
if (Object.keys(errors).length) {
dispatch({ type: 'SET_ERRORS', errors });
dispatch({ type: 'SUBMIT_END' });
return;
}
// Simulate async operation
await fakeApiCall(state);
dispatch({ type: 'SUBMIT_END' });
};
Each dispatch was self-explanatory, and the reducer managed how the state should change. This pattern not only helped me avoid bugs but also improved my understanding of how the component evolved over time.
Scaling Components
Later, when I built a React table UI with filters, sort options, and pagination, I didn’t even consider useState
. I directly went with useReducer
. Managing all the UI states (like selected filters, sort direction, and current page) in one reducer helped me scale features faster without bloating the component.
One of my favorite projects where I followed this pattern was a quiz builder, where state had to track current question, selected answers, user progress, and score. Managing all of this with useState
would’ve led to tangled logic. Instead, defining clear actions like NEXT_QUESTION
, SELECT_ANSWER
, and CALCULATE_SCORE
in a reducer made the flow intuitive.
It reminded me of React interview quizzes I had previously explored—cleanly structured, stateful apps that benefit from predictable state transitions.
Adding Middleware-Like Behavior
Another neat trick I used was integrating side effects just outside the reducer. Since reducers must be pure, I kept side effects (like logging or analytics) in the dispatch wrapper. This pattern kept things testable and modular—something I learned while working on multi-step forms and wanted a way to log each step’s entry without cluttering the reducer.
Final Thoughts
Replacing useState
with useReducer
isn’t about complexity—it’s about structure. It may feel like an overhead at first, especially for smaller components. But as your logic grows, the benefits become undeniable. Fewer bugs, better organization, and a mental model that’s easy to reason about.
If your component feels like it’s juggling too many useState
variables or the update logic is getting tangled, try rewriting it using useReducer
. It might just bring the clarity you’ve been missing.