← Back to question bank
React AppMidMedium#4005 · 40m

Implement useReducer state machines in a product component

Use useReducer state machines to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.

Answer Strategy

useReducer becomes worth its weight when transitions outnumber values. The interview signal is naming the alphabet of states and the alphabet of events before writing code, then writing the reducer as a switch on state.status that returns the same state for any event the current node does not accept. Illegal transitions are no-ops, not exceptions — UI dispatches noisily and the model decides what is allowed.

Encode state shape as a discriminated union, not a flat object with optional fields. CheckoutState narrows on .status so TypeScript prevents impossible reads inside a branch (you cannot access trackingId from cart). This is the difference between "the reducer protects the model" and "every caller has to remember the rules" — and the latter is what produces the broken-checkout bug at 2am.

Adjacent traps: dispatching async events directly (use an effect or middleware), letting the reducer touch refs or globals (it must be pure), and letting one event mutate two unrelated subtrees of state (split into two reducers via useReducer composition). The reference reducer is exhaustively switched on .status, so adding a new state forces a TypeScript error at every call site that needs to handle it.

Reference Implementation: Checkout State Machine With Reducer

A discriminated-union state with a switch-per-state reducer; illegal transitions return the previous state unchanged.

// Order checkout flow modeled as an explicit state machine. The senior tell
// is that illegal transitions return state unchanged instead of crashing or
// silently advancing — the reducer is a contract, not a junk drawer.

type CheckoutState =
  | { status: 'cart'; itemCount: number }
  | { status: 'confirming'; itemCount: number }
  | { status: 'paying'; itemCount: number }
  | { status: 'shipped'; itemCount: number; trackingId: string }
  | { status: 'failed'; reason: string; from: 'paying' | 'confirming' };

type CheckoutEvent =
  | { type: 'add'; count: number }
  | { type: 'confirm' }
  | { type: 'pay' }
  | { type: 'pay-success'; trackingId: string }
  | { type: 'pay-failure'; reason: string }
  | { type: 'reset' };

export function checkoutReducer(
  state: CheckoutState,
  event: CheckoutEvent
): CheckoutState {
  switch (state.status) {
    case 'cart':
      if (event.type === 'add') return { ...state, itemCount: state.itemCount + event.count };
      if (event.type === 'confirm' && state.itemCount > 0)
        return { status: 'confirming', itemCount: state.itemCount };
      return state;
    case 'confirming':
      if (event.type === 'pay') return { status: 'paying', itemCount: state.itemCount };
      if (event.type === 'reset') return { status: 'cart', itemCount: state.itemCount };
      return state;
    case 'paying':
      if (event.type === 'pay-success')
        return { status: 'shipped', itemCount: state.itemCount, trackingId: event.trackingId };
      if (event.type === 'pay-failure')
        return { status: 'failed', reason: event.reason, from: 'paying' };
      return state;
    case 'shipped':
    case 'failed':
      if (event.type === 'reset') return { status: 'cart', itemCount: 0 };
      return state;
  }
}

export function CheckoutPanel() {
  const [state, dispatch] = React.useReducer(checkoutReducer, {
    status: 'cart',
    itemCount: 0,
  });
  return (
    <section aria-label="Checkout">
      <p>Status: {state.status}</p>
      {state.status === 'cart' && (
        <button onClick={()=> dispatch({ type: 'confirm' })} disabled={state.itemCount= 0}>
          Confirm
        </button>
      )}
      {state.status === 'confirming' && (
        <button onClick={()=> dispatch({ type: 'pay' })}>Pay</button>
      )}
    </section>
  );
}

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Reducer purity
Test the reducer in isolation without React. For each pair of (state, event), assert the next state. Cover legal transitions, illegal transitions (must be no-ops), and failure paths that capture the origin for routing.
Exhaustiveness
Add a never-fallback in default branches if you switch on event.type. The TypeScript compiler should flag any new state that forgets to handle a relevant event — that is the safety net for adding "refunding" later without breaking pay flow.
Integration
One Testing Library test that drives the whole machine through the UI: add items, confirm, pay, succeed. Asserts that a Pay button is absent until confirming and that the shipped status renders the tracking id.
import { describe, it, expect } from 'vitest';

describe('checkoutReducer', () => {
  it('rejects pay before confirm', () => {
    const after = checkoutReducer({ status: 'cart', itemCount: 1 }, { type: 'pay' });
    // Cart cannot skip to paying — illegal transitions are no-ops.
    expect(after).toEqual({ status: 'cart', itemCount: 1 });
  });

  it('rejects confirm with empty cart', () => {
    const after = checkoutReducer({ status: 'cart', itemCount: 0 }, { type: 'confirm' });
    expect(after.status).toBe('cart');
  });

  it('advances cart -> confirming -> paying -> shipped', () => {
    let s: CheckoutState = { status: 'cart', itemCount: 0 };
    s = checkoutReducer(s, { type: 'add', count: 2 });
    s = checkoutReducer(s, { type: 'confirm' });
    s = checkoutReducer(s, { type: 'pay' });
    s = checkoutReducer(s, { type: 'pay-success', trackingId: 'TR-1' });
    expect(s).toEqual({ status: 'shipped', itemCount: 2, trackingId: 'TR-1' });
  });

  it('captures failure with origin for retry routing', () => {
    let s: CheckoutState = { status: 'paying', itemCount: 1 };
    s = checkoutReducer(s, { type: 'pay-failure', reason: 'card declined' });
    expect(s).toEqual({ status: 'failed', reason: 'card declined', from: 'paying' });
  });
});

Interviewer Signal

Shows whether you can explain React behavior while building maintainable product UI.

Constraints

  • Name what is render-derived and what is stored state.
  • Keep side effects owned by events or effects deliberately.
  • Provide a testable boundary for business logic.

Model Answer Shape

  • Start from the user workflow and state ownership.
  • Move pure decisions out of JSX when the branch logic grows.
  • Use React primitives to express ownership, not to hide unclear state.

Tradeoffs

  • Colocating state improves clarity until sibling coordination becomes the real problem.
  • Memoization helps only after render cost or identity churn is measured.

Edge Cases

  • Strict Mode re-running development effects.
  • Stale closures after async work resolves.
  • Unmounts and route changes during in-flight operations.

Testing And Proof

  • Reducer or pure function test for core state transitions.
  • Interaction test for the user workflow.
  • Regression case for stale or repeated async behavior.

Follow-Ups

  • How would this change with server rendering?
  • Where would you place this state in a larger app?