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?
answer
- action alone is not enough
- transition takes state and event
- outer switch on the current state
- unknown pair returns the same state
- the table is the specification
basics
~20 sMake 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.
solid answer
~50 sSwitching on `action.type` alone means every action is legal in every state, so the second SUBMIT re-enters the submitting state and the flow fires twice. I restructure the reducer so the current state is the outer decision: `switch (state.status)`, and inside each state handle only the actions that state accepts, falling through to `return state` for everything else. A second SUBMIT while `status === 'submitting'` then hits no case and is simply ignored — the machine, not the UI, is the guarantee. For a flow with more than a handful of states I lift the rules into a transition table, an object keyed by state and then action type, so the whole graph is readable in one screen and testable as data. Disabling the button is still worth doing, but as UX, not as the correctness mechanism.
code
typescript · 18 linestype Status = 'idle' | 'submitting' | 'succeeded' | 'failed';
type Action = { type: 'SUBMIT' } | { type: 'RESOLVE' } | { type: 'REJECT' } | { type: 'RESET' };
const transitions: Record<Status, Partial<Record<Action['type'], Status>>> = {
idle: { SUBMIT: 'submitting' },
submitting: { RESOLVE: 'succeeded', REJECT: 'failed' },
succeeded: { RESET: 'idle' },
failed: { SUBMIT: 'submitting', RESET: 'idle' },
};
function reducer(status: Status, action: Action): Status {
// no entry for this pair means the transition is illegal: stay put
return transitions[status][action.type] ?? status;
}
console.log(reducer('submitting', { type: 'SUBMIT' })); // 'submitting'
export { reducer };go deeper
Know that a reducer receives both the current state and the action, and that it may return the state unchanged. Say that an unwanted second submit should be ignored by the reducer, not just by a disabled button.
Explain the difference between switching on action.type and switching on the current state first, and show that returning the same state reference makes React skip the re-render.
Demonstrate the production reasoning: several controls can dispatch the same action, so legality must live in the transition function; add dev-only logging of rejected pairs and table-driven tests over the whole graph.
Argue about where the flow's rules live across a codebase — a shared transition-table convention makes flows reviewable and diffable, and be ready to say what it costs when a flow genuinely needs conditional, data-dependent branching.
## Action-first reducers have no notion of legality The reflex shape for a reducer is a `switch (action.type)`. It reads well and it is fine while the flow is linear, but it encodes exactly one rule: *this action always does this*. Nothing in it says a SUBMIT is only meaningful from `idle`, or that a RESOLVE arriving in `idle` is a mistake. Every action is accepted from every state. That is what makes the double-click bug possible. The button dispatches SUBMIT twice before the first render that would disable it commits; the reducer happily produces `submitting` twice; whatever the component does in response to entering `submitting` runs twice. ## State-first: make the transition a function of both A state machine's transition function takes the current state *and* the event. Mirror that in the reducer by making the state the outer switch: ```typescript function reducer(state: State, action: Action): State { switch (state.status) { case 'idle': return action.type === 'SUBMIT' ? { status: 'submitting' } : state; case 'submitting': if (action.type === 'RESOLVE') return { status: 'succeeded', id: action.id }; if (action.type === 'REJECT') return { status: 'failed', error: action.error }; return state; case 'succeeded': return action.type === 'RESET' ? { status: 'idle' } : state; case 'failed': if (action.type === 'SUBMIT') return { status: 'submitting' }; if (action.type === 'RESET') return { status: 'idle' }; return state; } } ``` The second SUBMIT lands in the `submitting` case, matches nothing, and returns the identical state object. Returning the *same reference* matters: React bails out of re-rendering when a `useReducer` state is unchanged by `Object.is`, so ignoring an illegal action costs nothing. Read the code above as a specification and you can see the legal graph without running it. That is the real deliverable — the reducer now documents which sequences are possible. ## The transition table Once there are more than four or five states, the nested conditionals get noisy and the graph stops being visible. Lift it into data: ```typescript const transitions = { idle: { SUBMIT: 'submitting' }, submitting: { RESOLVE: 'succeeded', REJECT: 'failed' }, succeeded: { RESET: 'idle' }, failed: { SUBMIT: 'submitting', RESET: 'idle' }, } as const; ``` The reducer becomes a lookup: find the next status for this state and action, and if there is none, stay put. A table has properties nested `if`s do not. It is enumerable, so you can assert in a test that no state can reach `succeeded` except from `submitting`. It is reviewable, because adding a transition is a one-line diff in a place reviewers can see whole. And it separates *whether* a transition is legal from *what data* the new state carries, which is usually a small per-transition function applied after the lookup. ## Guards Some transitions are legal only under a condition — a WIZARD_NEXT from `step2` only when the form is valid. That is a guard: a predicate evaluated during the transition. In a hand-written reducer it is an `if` inside the matching case; in a table it is a small function stored beside the target state. What it must not become is a check at the call site, because then the rule lives wherever somebody happened to dispatch from, and the next call site will forget it. ## Where this leaves the UI Disabling the Submit button while `status === 'submitting'` is still correct and still worth doing — it tells the user what is happening. But treat it as presentation. The button is one of several dispatchers (an Enter keypress, a retry link, a parent effect), and a machine that only holds because one particular control was greyed out is not guarded at all. Push the rule down into the transition function and every dispatcher inherits it. One dev-only refinement: rather than silently returning `state`, log the rejected pair in development. A quiet ignore is right in production, but during development "REJECT arrived while idle" is a genuine signal that some caller is out of step with the flow, and you want to see it rather than have it swallowed. ## What it does not fix Guarding transitions constrains what the *state* can do; it does not by itself stop work that was already started from finishing. The machine can ignore a late RESOLVE, which is usually the outcome you want, but the ignored action is a hint that the flow needs a cancellation story too. Keep the two concerns separate: legality of transitions in the reducer, lifecycle of the in-flight work where that work is started.
- Should an illegal transition throw instead of being ignored?Not in production — throwing turns a harmless double-click into a crashed subtree. The useful compromise is to ignore it in production and warn in development, logging the state and action that were rejected. That keeps users safe while surfacing genuinely confused callers to the developer who can fix them.
- What does returning the same state object buy you?React compares the value a `useReducer` reducer returns with `Object.is`; if it is the identical reference, the component bails out and does not re-render for that dispatch. So ignoring an illegal action is free. Building a fresh but structurally equal object instead would re-render every time a stray action arrives.
- How do you test that the illegal transitions really are impossible?Because the machine is data, you can iterate every state–action pair and assert the result: SUBMIT from 'submitting' must yield 'submitting', RESOLVE from 'idle' must yield 'idle'. That is a handful of table-driven cases covering the whole graph, and it fails loudly the day someone adds a transition that was not intended.
- Where do conditional transitions — legal only when the form is valid — belong?Inside the transition, as a guard: a predicate evaluated when the pair matches, falling back to the current state when it fails. Putting the check at the dispatch site scatters the rule across call sites, and the next place that dispatches the same action will not repeat it.
saying these in an interview costs you the question
- Says disabling the button is enough to prevent it
- Switches on action.type and adds an isSubmitting flag
- Throws on any unexpected action in production
- Puts the legality check at each dispatch call site
- Builds a new equal state object when ignoring an action