DEV Community

Cover image for When to Replace Multiple useState with useReducer
ReactChallenges
ReactChallenges

Posted on

When to Replace Multiple useState with useReducer

You start with one useState. Then another. Then a third. Before you know it, a single event handler calls four setters in sequence and you've lost track of which combination of state values is valid. Sound familiar?

👉 Try it in practice: Calculator with reducer

React gives us two built-in tools for component state: useState for independent values, and useReducer for grouped state transitions. The problem is that nobody tells you where the line is. You only notice you crossed it when the code starts hurting.

Here's how to recognize that moment — and what to do about it.

The smell: state that moves together

The clearest signal is when a single user action needs to update multiple state values at once. If you see a pattern like this:

const [isOpen, setIsOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
Enter fullscreen mode Exit fullscreen mode

And then an event handler that touches all of them:

function handleSelect(item: string) {
  setSelectedItem(item);
  setIsOpen(false);
  setError(null);
  setIsSubmitting(false);
}
Enter fullscreen mode Exit fullscreen mode

You're describing a state transition — moving from one valid state to another — but you're encoding it as a sequence of independent mutations. The more setters you call in one handler, the more likely you are to forget one, or call them in the wrong order, or leave the component in an impossible state like isSubmitting: true and error: "timeout" at the same time.

A second signal: derived state scattered across the component. If you find yourself writing if (isOpen && selectedItem && !error) in five different places, those three booleans aren't independent. They describe a single concept — the UI mode the component is in — and you're reconstructing it from pieces over and over.

A third signal: reset logic that looks like a checklist. When closing a modal or resetting a form, if you need six setX(initialX) calls in a row, the state belongs together.

The fix: describe state transitions, not state mutations

useReducer lets you define the legal transitions explicitly:

type State =
  | { status: "idle" }
  | { status: "selecting"; items: Item[] }
  | { status: "selected"; item: Item }
  | { status: "submitting"; item: Item }
  | { status: "error"; item: Item; message: string };

type Action =
  | { type: "OPEN_LIST"; items: Item[] }
  | { type: "SELECT_ITEM"; item: Item }
  | { type: "SUBMIT" }
  | { type: "SUBMIT_ERROR"; message: string }
  | { type: "RESET" };
Enter fullscreen mode Exit fullscreen mode

Instead of five separate booleans and nullables, you have a single state value that is always in exactly one valid mode. The reducer maps actions to states:

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "OPEN_LIST":
      return { status: "selecting", items: action.items };

    case "SELECT_ITEM":
      if (state.status !== "selecting") return state;
      return { status: "selected", item: action.item };

    case "SUBMIT":
      if (state.status !== "selected") return state;
      return { status: "submitting", item: state.item };

    case "SUBMIT_ERROR":
      if (state.status !== "submitting") return state;
      return { status: "error", item: state.item, message: action.message };

    case "RESET":
      return { status: "idle" };

    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice what disappeared: the impossible states. There is no branch where isSubmitting is true and selectedItem is null, because the type system won't allow it. The reducer rejects transitions that don't make sense — if the user somehow triggers SUBMIT while the component is idle, it returns the current state unchanged.

The component becomes a thin shell that dispatches intent:

const [state, dispatch] = useReducer(reducer, { status: "idle" });

return (
  <div>
    {state.status === "selecting" && (
      <ItemList
        items={state.items}
        onSelect={(item) => dispatch({ type: "SELECT_ITEM", item })}
      />
    )}
    {state.status === "selected" && (
      <button onClick={() => dispatch({ type: "SUBMIT" })}>
        Confirm {state.item.name}
      </button>
    )}
    {state.status === "submitting" && <Spinner />}
    {state.status === "error" && <ErrorMessage message={state.message} />}
  </div>
);
Enter fullscreen mode Exit fullscreen mode

No more if (isOpen && selectedItem && !isSubmitting && !error). The component renders based on a single discriminant — state.status — and TypeScript narrows the available properties automatically.

When to switch (and when not to)

Not every component needs useReducer. Here is a practical heuristic:

Stick with useState Consider useReducer
1-2 independent values 3+ values that change as a unit
Each setter is called in isolation One handler calls multiple setters
Reset is one or two lines Reset requires a checklist of initial values
State combinations are always valid Some combinations are impossible or bug-prone
The component is 50 lines The component is 150+ lines and growing

If you find yourself writing a function called resetForm that exists solely to call six setters in sequence, you've already built a reducer — just one spread across your component body.

Don't use useReducer for everything. A single boolean toggle doesn't need a reducer. Two independent counters in the same component don't need to be merged. The reducer shines when state values are coupled — when changing one implies changing others, and the set of legal combinations is smaller than the Cartesian product of all values.

The testing advantage

The most underrated benefit of useReducer is that the reducer is a pure function. You can test every state transition without rendering a component:

test("selecting an item from idle should do nothing", () => {
  const state = { status: "idle" as const };
  const next = reducer(state, {
    type: "SELECT_ITEM",
    item: { id: 1, name: "Foo" },
  });
  expect(next).toBe(state); // no transition allowed
});

test("submitting a selected item moves to submitting state", () => {
  const state = { status: "selected" as const, item: { id: 1, name: "Foo" } };
  const next = reducer(state, { type: "SUBMIT" });
  expect(next.status).toBe("submitting");
  expect(next.item.name).toBe("Foo");
});
Enter fullscreen mode Exit fullscreen mode

No mocks, no act(), no render. Just a function that takes state and returns state. When the core logic lives in a reducer, your component tests become thin integration checks, and the business logic is tested fast, in isolation, with full confidence.

👉 Try it in practice: Calculator with reducer

Top comments (0)