Reducers and one status for UI state
One status holds only the screens you drew; a reducer judges each event against it.
Predict first
Before you read: predict
ExamHall's submit button. Development build, no StrictMode.
A candidate clicks Submit, and the server rejects it. She clicks Try again, and while that request is still waiting, she clicks Try again once more.
What is on screen now, and how many submissions has the server received?
function SubmitExam({ api }) { const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitted, setIsSubmitted] = useState(false); const [hasError, setHasError] = useState(false); async function submit() { setIsSubmitting(true); try { await api.submit(); setIsSubmitted(true); } catch { setHasError(true); } setIsSubmitting(false); } return ( <section> {isSubmitting && <p>Submitting…</p>} {isSubmitted && <p>Submitted</p>} {hasError && <p role="alert">Submission failed <button onClick={submit}>Try again</button></p>} {!isSubmitted && <button disabled={isSubmitting} onClick={submit}>Submit</button>} </section> );}The 30-second version
Separate booleans for one flow multiply: three flags hold eight combinations, and every handler must set each one right. Keep one status with exactly the values you drew, each with its own data, and render from it. In a reducer, actions name what happened and the current status judges each one.
One status holds only the screens you drew; a reducer judges each event against it.
How it works
Step 1 of 11
In the kitchen
- An old machine had three lamps, coin in, vending and sold out, each switched by a different part → Separate flags, each set by its own line (steps 1–2)
Analogy: A snack vending machine's controller board (retail automation)
| At the vending machine | In React |
|---|---|
| An old machine had three lamps, coin in, vending and sold out, each switched by a different part | Separate flags, each set by its own line (steps 1–2) |
| A jam left "vending" and "coin in" lit together, a state no manual shows | A combination nobody drew (step 2) |
| The new board holds one mode at a time: waiting, paid, vending, sold out | One status (step 3) |
| Each key press and coin is reported to the board as what happened, never as "motor on" | Actions are events (step 7) |
| A second press while it vends is ignored; the board looks at its mode first | The status judges each event (step 8) |
| Engineers test the board on a bench, feeding it presses and coins, with no cabinet | A reducer tested without React (step 9) |
In real terms: the screen can show only what the status can hold, and only the reducer moves it.
Breaks down when: a machine's board holds the motor too; a reducer only computes the next state, and handlers send the requests.
Contrasting case: a page with one independent toggle, such as dark mode. One boolean has no partner to fall out of step with; a reducer adds code and fixes nothing.
Examples, run for real
4.1 ExamHall EXH-4150 and MR !4450: how many screens the flow can reach
ExamHall's submit flow kept four booleans: confirming, submitting, submitted and error. The script mounted it in React and played every sequence of up to five steps (Submit, Confirm, Cancel, Try again, a server success, a server failure), recording which combination of status lines the screen showed after each step. MR !4450 replaced the flags with one status from a reducer and played the same sequences.
4.1 EXH-4150 four booleans: 2^4 = 16 combinations · every sequence of up to 5 steps (7776 of length 5) reached 8, 3 of them never drawn
4.1 MR !4450 one status: 5 values · the same sequences reached 5, 0 never drawn
Eight of the sixteen combinations appeared, and three are screens nobody designed (steps 1–2). With one status the screen can reach only the five values the type lists (step 3).
4.2 Chat: a call reducer with no React
A chat app's call button keeps its rules in a reducer judged by status (steps 6–8):
function callFlow(state, event) {
switch (state.status) {
case 'idle': return event.type === 'JOIN_CLICKED' ? { status: 'joining' } : state;
case 'joining':
if (event.type === 'JOIN_ACCEPTED') return { status: 'in_call', roomId: event.roomId };
if (event.type === 'LEAVE_CLICKED') return { status: 'idle' };
return state;
case 'in_call': return event.type === 'LEAVE_CLICKED' ? { status: 'idle' } : state;
}
}
The script calls it as a plain function over an event log, a double-clicked Join included:
4.2 callFlow over 4 events, no React: JOIN_CLICKED -> joining · JOIN_CLICKED -> joining (same object) · JOIN_ACCEPTED -> in_call r-3 · LEAVE_CLICKED -> idle
The second click returned the same object, so React has nothing to change, and the test needed no component (step 9).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A chat app's message row. Development build, no StrictMode.
A user taps Send; the server rejects it with offline. She taps Retry.
What does the row show while the retry waits, and after the server accepts it?
type Message = | { status: 'draft' } | { status: 'sending' } | { status: 'failed'; error: string } | { status: 'sent'; id: string }; function message(state: Message, event: MessageEvent): Message { switch (event.type) { case 'SEND_CLICKED': return { status: 'sending' }; case 'SERVER_REJECTED': return { status: 'failed', error: event.error }; case 'RETRY_CLICKED': return { ...state, status: 'sending' }; case 'SERVER_ACCEPTED': return { ...state, status: 'sent', id: event.id }; }} function MessageRow({ text, api }) { const [msg, dispatch] = useReducer(message, { status: 'draft' }); async function send(type) { dispatch({ type }); try { const { id } = await api.post(text); dispatch({ type: 'SERVER_ACCEPTED', id }); } catch (e) { dispatch({ type: 'SERVER_REJECTED', error: e.message }); } } return ( <li> {text} {msg.status === 'draft' && <button onClick={() => send('SEND_CLICKED')}>Send</button>} {msg.status === 'sending' && <small>Sending…</small>} {msg.status === 'sent' && <small>✓</small>} {'error' in msg && <span role="alert">Not sent: {msg.error} <button onClick={() => send('RETRY_CLICKED')}>Retry</button></span>} </li> );}Question 2 of 7
A chat app's call button keeps its state in a reducer. Development build, no StrictMode.
A user taps Join call, then taps Leave before the server answers. The server's answer then arrives with room r-7.
What does the user see?
function call(state, action) { switch (action.type) { case 'SET_STATUS': return { status: action.status, roomId: action.roomId ?? null }; }} function CallButton({ api }) { const [state, dispatch] = useReducer(call, { status: 'idle', roomId: null }); async function join() { dispatch({ type: 'SET_STATUS', status: 'joining' }); const { roomId } = await api.join(); dispatch({ type: 'SET_STATUS', status: 'in_call', roomId }); } const leave = () => dispatch({ type: 'SET_STATUS', status: 'idle' }); if (state.status === 'idle') return <button onClick={join}>Join call</button>; if (state.status === 'joining') return <p>Joining… <button onClick={leave}>Leave</button></p>; return <p>In call {state.roomId} <button onClick={leave}>Leave</button></p>;}Question 3 of 7
A chat app's notification settings screen has five switches: sound, previews, badges, vibrate and mentions only. Each switch sets its own useState value, nothing changes two of them together, and no bug has been filed against the screen. In review, a teammate asks to move all five into one useReducer.
Which reply matches react.dev's guidance?
Question 4 of 7
A chat composer. Development build, no StrictMode.
Users report two status lines at once, and the odd duplicate message.
Which single change leaves no sequence of taps and server replies showing two status lines, or sending a second message while one waits?
function Composer({ api }) { const [sending, setSending] = useState(false); const [sent, setSent] = useState(false); const [failed, setFailed] = useState(false); async function send() { setSending(true); try { await api.post(); setSent(true); } catch { setFailed(true); } setSending(false); } return ( <div> {sending && <small>Sending…</small>} {sent && <small>Sent ✓</small>} {failed && <small>Not sent <button onClick={send}>Retry</button></small>} {!sent && <button disabled={sending} onClick={send}>Send</button>} </div> );}Question 5 of 7
Write ExamHall's submit flow as one reducer, submitFlow(state, event), from a blank file, with no React. Statuses: idle, confirming, submitting, submitted (with receiptId) and failed (with error); start at { status: 'idle' }. Events: SUBMIT_CLICKED, CANCELLED, CONFIRMED, SERVER_ACCEPTED (receiptId), SERVER_REJECTED (error) and RETRY_CLICKED. The flow: Submit opens the confirmation; Cancel closes it; Confirm submits; a reply ends in submitted or failed; Retry from failed submits again. An event the current status does not accept returns the same state object, and no state keeps a field of an earlier status.
Export submitFlow.
Question 6 of 7
A chat app's attachment button, in two versions that differ only in the reducer they pass. Development build, no StrictMode.
Version 1 renders <AttachButton reducer={attachV1} api={api} />; version 2 renders <AttachButton reducer={attachV2} api={api} />. In each, a user taps Attach and then Cancel while the upload is still running. The upload then finishes, and the server answers with file f-9.
Which version ends up showing "Attached f-9"?
function attachV1(state, event) { switch (event.type) { case 'ATTACH_CLICKED': return { status: 'uploading' }; case 'CANCEL_CLICKED': return { status: 'idle' }; case 'SERVER_ACCEPTED': return { status: 'attached', fileId: event.fileId }; default: return state; }} function attachV2(state, event) { switch (state.status) { case 'idle': return event.type === 'ATTACH_CLICKED' ? { status: 'uploading' } : state; case 'uploading': if (event.type === 'SERVER_ACCEPTED') return { status: 'attached', fileId: event.fileId }; if (event.type === 'CANCEL_CLICKED') return { status: 'idle' }; return state; default: return state; }} function AttachButton({ reducer, api }) { const [state, dispatch] = useReducer(reducer, { status: 'idle' }); async function attach() { dispatch({ type: 'ATTACH_CLICKED' }); const { fileId } = await api.upload(); dispatch({ type: 'SERVER_ACCEPTED', fileId }); } if (state.status === 'idle') return <button onClick={attach}>Attach</button>; if (state.status === 'uploading') return <p>Uploading… <button onClick={() => dispatch({ type: 'CANCEL_CLICKED' })}>Cancel</button></p>; return <p>Attached {state.fileId}</p>;}Question 7 of 7
A chat app's incoming call rings for 30 seconds and then shows as missed, unless the user answers or declines first. The rest of the call screen already runs on a reducer.
Which plan matches the React and XState docs?
Go deeper
Pro tips
- Test that each status ignores the events it must ignore. Assert the same object comes back (
assert.equal(next, state)), not only the happy path. When: any reducer behind buttons a user can double-click, or behind replies that can arrive late. Verify (RAN,tips.mjs): the first line below; the reference passes those tests. - While debugging, log inside the reducer. One line per action, with the status before and after, shows which event moved the screen, as react.dev's debugging note suggests. When: a screen shows a state you cannot explain. Verify (RAN,
tips.mjs): the second line below.
tip 1 event-only-variant.mjs: same-object tests K3, K4, K6 fail · the reference passes them
tip 2 a logging reducer over 4 events: SUBMIT_CLICKED: idle -> confirming · CONFIRMED: confirming -> submitting · CONFIRMED: submitting -> submitting (ignored) · SERVER_REJECTED: submitting -> failed
Source
- react.dev: Extracting State Logic into a Reducer, read 2026-10-14 from the page's source. "Each action describes a single user interaction, even if that leads to multiple changes in the data." Steps 6, 7, 9, 10, P3, D2. Valid as of 2026-10-14.
- react.dev: useReducer, read 2026-10-14. The
[state, dispatch]signature and the reducer's(state, action)arguments. Step 6. Valid as of 2026-10-14. - stately.ai: What are state machines and statecharts?, read 2026-10-14. "Using state machines makes it easier to find impossible states and spot undesirable transitions." "Statecharts add extra features not available in ordinary state machines, including hierarchy, concurrency and communication." Steps 3, 11. Valid as of 2026-10-14.
- stately.ai: Delayed (after) transitions, read 2026-10-14. Step 11, D2. Valid as of 2026-10-14.
Related lessons
- Read first: Narrowing and discriminated unions
- Read first: Explicit state machines and guarded transitions
- Related: Structural typing, interfaces and declaration merging
- Related: useState: hook slots, snapshots and batching
- Related: Render and commit
- Related: Stale closures, refs and useEffectEvent
- Related: Forms, Actions and optimistic UI
Checked against the tools and docs as of 2026-10-14.