skip to content

Reducers and Complex State

Moving from scattered setState calls to a single reduce function once several fields change together or transitions have rules. Interviewers ask this to hear you reason about state transitions as a whole rather than field by field.

part ofReactoverview, primer and where to startread it →
on this pageshow

explore

questions

9

A React component tracks a fetch with three useState booleans — isLoading, isError, isSuccess — plus data and error. Why is that considered a bug-prone way to model the state, and what do you model instead?

level: juniorimportance: must knowfreq 70%

answer

  1. count the combinations
  2. most combinations are meaningless
  3. one field instead of three
  4. impossible states, unrepresentable
  5. status union drives one switch

basics

~20 s

Three independent booleans allow eight combinations, and most are nonsense — loading and error both true, or success true with no data. Replace them with one status value (idle, loading, success, error) so contradictory states cannot be represented at all.

solid answer

~50 s

Three booleans give you 2^3 = 8 combinations, but the flow only has four meaningful states. The other four are bugs waiting to happen: `isLoading` and `isError` true together, `isSuccess` true while `data` is still `null`, or a retry that sets `isLoading` back to `true` without clearing the previous `error`, so the UI shows a spinner and a stale error at once. Nothing in the type system stops any of that — correctness depends on every setter remembering to reset the other flags. The fix is to collapse the flags into a single `status` field with the values `'idle' | 'loading' | 'success' | 'error'`, held in one `useState` or one reducer. A transition is then a single assignment, the render branches on `status` with one `switch`, and the impossible combinations simply do not exist.

code

typescript · 24 lines
typescript
type FlagState = {
  isLoading: boolean;
  isError: boolean;
  isSuccess: boolean;
  data: string[] | null;
  error: string | null;
};

// type-checks, but describes nothing real
const contradiction: FlagState = {
  isLoading: true,
  isError: true,
  isSuccess: true,
  data: null,
  error: 'boom',
};

type Status = 'idle' | 'loading' | 'success' | 'error';
type StatusState = { status: Status; data: string[] | null; error: string | null };

// only four values are expressible at all
const legal: StatusState = { status: 'loading', data: null, error: null };

export { contradiction, legal };

go deeper

for a junior

Be ready to count the combinations out loud: three booleans mean eight possible states while the flow has four. Say plainly that you would store one status value instead.

for a middle

Explain the mechanism behind the classic retry bug — a setter that updates one flag and leaves another stale — and show the single-status refactor, including how a union type makes the render branch exhaustive.

for a senior

Show where you would put the state once transitions depend on the current state, and argue for replacing the whole value on each transition rather than spreading a partial update that can carry stale fields forward.

for a principal

Frame it as a codebase-wide policy: an agreed request-state shape reused across features removes a recurring bug class and makes review mechanical. Be ready to say what that standard costs in flexibility.

## Why flags multiply Every independent boolean doubles the number of states your component can be in. Three booleans give eight combinations; four give sixteen. An async request, however, has about four meaningful states: nothing requested yet, in flight, finished successfully, finished with an error. So with three booleans you are carrying eight slots to express four ideas, and the extra four slots are all *illegal*: they describe situations the domain does not have. ```typescript // all of these type-check, none of them are meaningful { isLoading: true, isError: true, isSuccess: false } { isLoading: false, isError: false, isSuccess: true, data: null } { isLoading: true, isError: false, isSuccess: true } ``` The compiler cannot help, because nothing in the type says the three fields are related. The invariant lives only in your head and in the discipline of every setter. ## How the bug actually shows up The classic failure is a retry. The first attempt fails, so the handler sets `isError` to `true` and stores `error`. The user clicks Retry, and the handler sets `isLoading` back to `true` — but forgets `setIsError(false)` and forgets to clear `error`. Now the render tree hits `if (error) return <ErrorBanner/>` before it ever reaches the spinner branch, and the user sees the old failure while a new request is in flight. Nobody wrote a bug; somebody forgot a line. The second failure is ordering. Because the flags are separate pieces of state, the component's *render order* silently becomes the state machine: whichever `if` you check first wins when two flags disagree. Move the `if (isLoading)` branch above the `if (error)` branch during a refactor and the visible behaviour changes without any state change. ## The single-status model Collapse the flags into one field whose values are the states themselves: ```typescript type Status = 'idle' | 'loading' | 'success' | 'error'; const [status, setStatus] = useState<Status>('idle'); ``` Now a transition is one assignment. `setStatus('loading')` cannot leave a stale `error` flag behind, because there is no separate flag to leave behind — the previous state is *replaced*, not partially overwritten. Rendering becomes an exhaustive branch: ```typescript switch (status) { case 'idle': return null; case 'loading': return <Spinner/>; case 'error': return <ErrorBanner/>; case 'success': return <List/>; } ``` In TypeScript the union type also makes the branch checkable: add a fifth state and every `switch` that does not handle it can be made to fail the build. This is what "make impossible states unrepresentable" means in practice — you shrink the state space to exactly the legal states, so the class of bug disappears rather than being tested for. Derived booleans are still fine at the point of use. `const isBusy = status === 'loading'` is a read-only convenience computed from the one source of truth; the problem was never the word `isLoading`, it was storing it independently. ## Where the state lives For a single status plus payload, one `useState` holding an object is usually enough. Once transitions start depending on the current state — "a SUBMIT while already submitting must be ignored" — a reducer is the better home, because the transition logic then sits in one function instead of being spread across handlers. Either way, the important part is that the four fields move together as one value. A useful smell test in review: if two `set*` calls always have to happen together for the component to be correct, they are one piece of state that has been split by accident. ## React 19 note For form submissions you often do not model the pending state yourself at all — React 19's action-based APIs surface a pending flag for you. The flag-soup problem chiefly bites hand-rolled async state: data you fetch and store in a component, multi-step flows, and anything with more outcomes than "pending or not".

  • If someone objects that they like reading isLoading in the JSX, what do you tell them?
    Keep the name, drop the storage. `const isLoading = status === 'loading'` is derived at render time from the single source of truth, so it can never disagree with the other states. The problem was storing three independent values that had to be kept in sync by hand, not the readability of a boolean at the point of use.
  • Does the same argument apply to a component with only two booleans?
    Less forcefully, but yes if the two can contradict. Two booleans give four combinations; if one of them is illegal — say `isEditing` and `isSaving` both true when your UI has no such mode — you have the same class of bug with a smaller blast radius. If all four combinations are genuinely meaningful, they are independent state and two booleans are the honest model.
  • Where does the fetched data live once you have a status field?
    Alongside the status, in the same state value, so a transition replaces both at once. `setState({ status: 'success', data })` cannot leave a stale error behind. Storing `data` in a separate `useState` reintroduces the sync problem in a quieter form: the status says success while `data` is still the previous request's payload for one render.

saying these in an interview costs you the question

  • Says you just have to remember to reset the other flags
  • Claims the booleans can never disagree in practice
  • Adds a fourth boolean instead of collapsing to a status
  • Sets isLoading true on retry without clearing the error
  • Thinks the fix is reordering the if-branches in JSX

context

open as a page

Why must the reducer function you pass to React's useReducer be pure, and where do the side effects it might want to perform belong instead?

level: middleimportance: must knowfreq 58%

basics

~20 s

React runs reducers during rendering and may call one twice in development, so a reducer must only compute and return the next state from its arguments. Fetching, logging, storage writes, timers and randomness belong in the event handler or an effect.

open as a page

In React, what signals tell you a component should move from several useState calls to a single useReducer, and what do you actually gain by doing it?

level: middleimportance: must knowfreq 70%

basics

~20 s

Move to useReducer when several state fields change together on one event, when the next value depends on the previous one, or when the same update logic is repeated across handlers. You gain a single pure transition function you can test on its own.

open as a page

In React, why is the dispatch function returned by useReducer safe to leave out of a useEffect dependency array, and what does that stability let you avoid elsewhere?

level: juniorimportance: should knowfreq 45%

basics

~20 s

React guarantees that the dispatch function keeps the same identity for the whole lifetime of the component, so listing it in a dependency array can never cause the effect to re-run. That stability also means dispatch needs no useCallback when passed to memoized children.

open as a page

A React component holds request state as { status, data, error } with status typed 'idle' | 'loading' | 'success' | 'error'. TypeScript still lets you read data while status is 'error'. How would you model the state so each status carries exactly the data that state has?

level: middleimportance: should knowfreq 45%

basics

~20 s

Model the state as a discriminated union: one object variant per status, each declaring only the fields that status owns. Narrowing on status then makes data available only in the success variant and error only in the error variant, so nullable-everywhere fields disappear.

open as a page

A React component calls const [state, dispatch] = useReducer(reducer, buildInitialState(items)), where buildInitialState is expensive. What happens on every re-render, and how do you fix it?

level: middleimportance: should knowfreq 45%

basics

~20 s

buildInitialState(items) is an ordinary argument, so JavaScript evaluates it on every render even though React only uses its result on the first one. Pass the raw value plus the function as useReducer's third argument so React calls the initializer only on mount.

open as a page

A React submit flow uses a reducer whose switch is on action.type alone. A user double-clicks Submit, so a second SUBMIT arrives while the request is already in flight. How do you structure the reducer so only transitions that are legal from the current state are applied?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Make the transition a function of state and action, not of action alone: switch on the current status first and handle only the actions legal there, returning the state unchanged otherwise. A transition table keyed by state then action expresses the same rule as data.

open as a page

In a React useReducer, what is the difference between dispatching { type: 'setStatus', value: 'loading' } and dispatching { type: 'submitted' }, and why do reviewers push for the second style?

level: seniorimportance: should knowfreq 42%

basics

~20 s

The first is a remote-control setter: the component decides the new state and the reducer just writes it. The second names what happened and lets the reducer decide the consequences, so the rules live in one place and each user interaction is one action.

open as a page

Your React app has several hand-rolled useReducer state machines for wizards and async flows. When does moving them to a statechart library such as XState pay for itself, and when is it over-engineering?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

A statechart library pays off when flows outgrow a flat status list — nested states, concurrent regions, guards and delays repeated across features, or a graph non-engineers must review. For one component with four states and six transitions, a plain reducer is cheaper and clearer.

open as a page