How to Replace useState with useReducer in React

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.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top