Debug useReducer state machines under interview pressure
A React screen using useReducer state machines passes simple tests but breaks during repeated interaction. Find the likely root cause, patch it, and describe the longer-term design improvement.
Answer Strategy
The broken pattern in this question is "flat state with optional fields and an if-tree reducer". Two failure modes ship: callers read trackingId before payment completes (it is undefined, the screen renders blank), and dispatching pay from cart with zero items advances to paying because the reducer only checks the action.type, not the current status. The fix is a discriminated union for state and a switch on state.status that returns the previous state unchanged for any unsupported action.
Locate the boundary by asking what the state guarantees at each status. cart has no trackingId; paying has no trackingId either; shipped has one. The discriminated union tells TypeScript (and the reader) that reading trackingId in the cart branch is a compile error. The switch-per-status structure makes the reducer a contract: every legal pair of (state, event) is enumerated, every illegal pair is a no-op.
Adjacent traps: returning a new object even for no-op transitions (defeats referential equality and memoization), allowing one event to mutate two unrelated subtrees (split the reducer instead), and storing transient async progress inside the reducer (the reducer must be pure; let an effect own the in-flight intent). The regression test asserts identity preservation explicitly.
Regression Fix: Switch-Per-Status Reducer With Discriminated State
The fixed reducer narrows state with a discriminated union and rejects illegal transitions by returning the previous state reference unchanged.
// THE BUG: the original reducer treated state as a flat object with
// optional fields, so callers could read trackingId in the cart status
// or dispatch "ship" from "cart". Illegal transitions silently advanced
// state and the UI rendered with undefined fields. The fix narrows the
// state shape with a discriminated union and enforces transitions in a
// switch on state.status.
type CheckoutState =
| { status: 'cart'; itemCount: number }
| { status: 'paying'; itemCount: number }
| { status: 'shipped'; itemCount: number; trackingId: string };
type Action =
| { type: 'add' }
| { type: 'pay' }
| { type: 'pay-success'; trackingId: string };
export function reducer(state: CheckoutState, action: Action): CheckoutState {
switch (state.status) {
case 'cart':
if (action.type === 'add') return { ...state, itemCount: state.itemCount + 1 };
if (action.type === 'pay' && state.itemCount > 0)
return { status: 'paying', itemCount: state.itemCount };
return state;
case 'paying':
if (action.type === 'pay-success')
return { status: 'shipped', itemCount: state.itemCount, trackingId: action.trackingId };
return state;
case 'shipped':
return state;
}
}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.
import { describe, it, expect } from 'vitest';
describe('reducer regression', () => {
it('rejects pay with empty cart', () => {
const after = reducer({ status: 'cart', itemCount: 0 }, { type: 'pay' });
// The broken reducer advanced to paying with itemCount=0, which the
// payment screen then crashed on. Here we stay in cart unchanged.
expect(after).toEqual({ status: 'cart', itemCount: 0 });
});
it('blocks pay-success unless we are paying', () => {
const after = reducer(
{ status: 'cart', itemCount: 1 },
{ type: 'pay-success', trackingId: 'TR-1' }
);
expect(after.status).toBe('cart');
});
it('returns identical reference for no-op transitions', () => {
const before: CheckoutState = { status: 'shipped', itemCount: 1, trackingId: 'X' };
const after = reducer(before, { type: 'pay' });
// Identity preservation is what lets memoized children skip re-renders
// when an unrelated dispatch fires.
expect(after).toBe(before);
});
});Interviewer Signal
Tests whether you debug from ownership and lifecycle instead of random dependency-array edits.
Constraints
- State a hypothesis before changing code.
- Name what evidence would confirm the bug.
- Avoid broad rewrites unless the current API cannot express the behavior.
Model Answer Shape
- Reproduce the failing sequence first.
- Inspect ownership boundaries: local state, props, effects, subscriptions, and server data.
- Patch the minimal broken boundary and add a regression test.
Tradeoffs
- A minimal patch reduces risk, but repeated lifecycle bugs often justify a small reducer or custom hook.
- Adding dependencies can silence lint warnings while still preserving the wrong ownership model.
Edge Cases
- Double clicks and repeated submissions.
- Slow network responses arriving out of order.
- Component remount with stale persisted state.
Testing And Proof
- Failing interaction sequence.
- Out-of-order async response.
- Unmount cleanup.
Follow-Ups
- What would the code review comment say?
- What metric or log would show this in production?