A React component has grown to eight useState calls whose values always move together, and each handler now sets three or four of them in sequence. Would you convert it to useReducer, and what do you actually gain?
answer
- coupling, not the number of calls
- one intent, several fields
- the rule lives in one function, not eight handlers
- testable without a renderer
- batching already fixed the render argument
basics
~20 sYes — that is the canonical signal. Coupled fields updated together belong in one reducer, which moves the transition logic out of the handlers into one pure function, makes impossible combinations unrepresentable, and gives you something you can unit-test without rendering.
solid answer
~50 sYes, this is exactly the case `useReducer` exists for. The tell is not the count of `useState` calls but the coupling: when one user intent has to touch several fields at once, the rule for "what happens on submit" is spread across every handler, and the eighth caller is the one that forgets a field and leaves state in an impossible combination. Consolidating gives you a single function of `(state, action)` that names each transition once, so a handler just says what happened and the reducer decides what state that implies. Practical gains: transitions are unit-testable as a plain function, no rendering involved; `dispatch` is stable so effects and memoized children stop churning; and invalid combinations become easier to design out. What you do not gain is fewer renders — React 18 batches multiple `setState` calls in the same handler anyway — and you do pay in indirection, so a component with two independent booleans should stay on `useState`.
go deeper
Know the plain heuristic: independent, simple values suit useState, while several values that change together under named actions suit useReducer. Be able to name one of each from code you have written.
Explain what actually moves when you convert — the transition logic leaves the handlers and becomes one pure function — and be able to say that batching means render count is not the reason.
Justify the conversion by invariants and maintainability, walk through how you would slice the actions and test the reducer directly, and volunteer the limits: no render win, no sharing, real indirection cost.
Own it as an architectural boundary question: which state belongs local in a reducer, which is server state that a cache should own, and which warrants an external store — plus what convention you would set so teams do not convert reflexively.
## Read the signal correctly The number of `useState` calls is a weak signal on its own. Eight independent, unrelated pieces of state are perfectly fine as eight `useState` calls — each one changes for its own reason and nothing coordinates them. The signal that matters here is the second half of the description: **the values always move together, and handlers set several of them in sequence.** That pattern means the transition rules already exist in the codebase — they are just scattered, expressed implicitly as the particular sequence of setters each handler happens to call. Nothing names them, nothing enforces them, and nothing tests them. ## What consolidation actually buys **One place per transition.** After the change, `handleSubmit` says `dispatch({ type: 'submitted' })` and the reducer's `submitted` branch decides that this means `status: 'pending'`, `error: null`, and the retry counter incremented. The next engineer who needs to know what submitting does reads one branch instead of grepping handlers. **Fewer impossible states.** Independent setters let `status: 'error'` coexist with `error: null`, because nothing forces the pair to move together. In a reducer the pair is set in one expression, so the invalid combination requires deliberate effort to write. This is the strongest argument, and it strengthens further if you model status as a union of shapes rather than a bag of independent flags. **Testability without a renderer.** The reducer is a plain function. `expect(reducer(pending, { type: 'failed', error: e })).toEqual(...)` needs no component, no DOM, no act(). Edge cases that are painful to drive through the UI become one-line tests. **Stable identity downstream.** `dispatch` never changes identity, so effects that trigger updates can hold empty dependency arrays instead of listing several setters or state values, and memoized children receiving `dispatch` keep their memoization. This also dissolves a whole family of stale-closure bugs in intervals and subscriptions, because the callback no longer needs to close over current state — it just names an intent. **A readable audit trail.** Every state change flows through one function, so a single log line or a DevTools breakpoint there shows every transition the component makes, in order. ## What it does not buy **It is not a render optimisation.** Since React 18, automatic batching collapses multiple `setState` calls into one render everywhere — event handlers, promises, timeouts, native handlers — so the pre-18 folklore that a reducer saves renders no longer applies. Converting for performance is converting for the wrong reason. **It does not make state shared.** A reducer still lives in one component. If the problem is that distant components need this state, the answer is lifting, context, or an external store — a different decision entirely. **It is not free.** You add an action vocabulary, an indirection between the handler and the change, and a file that reviewers must hold in their head alongside the component. For two independent booleans that is a net loss in readability. ## The honest decision rule Reach for `useReducer` when at least one of these is true: - several fields change together under a small set of named transitions; - the next state depends on the current state in a non-trivial way, beyond an updater function's one-liner; - the component behaves as a state machine, where the same event means different things in different states; - you want the transitions unit-tested independently of the component; - an effect or subscription needs to update state without closing over it. Stay with `useState` when fields are genuinely independent, when there are only one or two of them, or when every update is a straight assignment from a DOM event. And before either, check the cheapest option: some of those eight values may not be state at all but derivable from the others during render, in which case deleting them beats consolidating them. ## How to present it in an interview Say yes, but justify it with coupling rather than count; name the concrete wins — one place per transition, impossible states designed out, plain-function tests, stable `dispatch`; then volunteer the non-win about batching and renders. Volunteering the limit is what separates a considered answer from a memorised one.
- Is it true that useReducer causes fewer re-renders than several useState calls?No, and that belief is a leftover from before React 18. Automatic batching now collapses multiple `setState` calls in the same handler — and in promises, timeouts and native handlers — into a single render, so both approaches render once. Convert for clarity, invariants and testability; never present render count as the reason.
- You said some of the eight values may not be state at all. What would you check first?Whether a value can be computed during render from the others or from props. A field like `isValid` that is always a function of the form values, or a `total` derived from a list, should be calculated in the render body rather than stored and kept in sync. Deleting redundant state removes whole classes of desynchronisation bugs before any consolidation decision.
- When would you keep useState even though several fields change together?When the group is small and every transition is a straight assignment from the event — say a two-field filter bar. The reducer's ceremony only pays once there is real transition logic: branches that depend on the current state, or invariants across fields. Reaching for it in a twenty-line component makes the code harder to read for no invariant gained.
saying these in an interview costs you the question
- Says useReducer is chosen because it re-renders less
- Uses the number of useState calls as the whole criterion
- Treats useReducer as a substitute for shared or global state
- Converts before checking whether some values are derivable
- Claims useReducer is always the more professional choice