State machines for UI
Model a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
The bug that booleans make
Picture a profile page with an "Upload avatar" button. The first version is simple: one flag, isUploading, turns the button into a spinner.
Then product asks for an error message, so you add isError. Then a success tick, so you add isDone. Then a Cancel button, so you add isCanceled. Each flag is set in a different event handler, and each one was correct on the day it was written.
Three weeks later a bug report arrives with a screenshot nobody designed: a red "Upload failed" message next to a green tick. The first file's virus scan had timed out, which set isError. The user picked a new file, and that handler set isUploading = true but forgot to clear isError, so a spinner and the error showed together. Then the slow scan of the first file finished after all and set isDone = true.
This pattern has a name: boolean soup. The screen's situation is spread across several independent flags, and every handler has to remember to set all of them correctly. Miss one line in one handler and the UI lands in a combination that makes no sense.
Count the combinations
Four booleans can be combined in 2 x 2 x 2 x 2 = 16 ways. The upload only has five situations that make sense: nothing chosen yet, uploading, finished, failed, and canceled. So 11 of the 16 combinations are impossible states: states the code can represent but the product never meant to have, like isUploading && isError or isDone && isCanceled.
Every new flag doubles the number of combinations. Five flags give 32, six give 64. The number of situations the product actually has grows by one at a time. The gap between the two is where the bugs live, and you can't test your way out of it because nobody writes tests for screens they never imagined.
It is not only uploads
- Data fetching:
isLoadinganderroranddatacan all be set at once, so the page shows stale data, a spinner, and an error banner together. - Forms: a submit button stays enabled while
isSubmittingis true because a different flag,isValid, controls it, and a double click sends two orders. - Media players: the button shows "Pause" while the video is actually stuck buffering, because
isPlayingwas set when the user pressed play, not when frames arrived. - Login: a user who closes the two-factor dialog is left with
isLoggedIn = falseandneedsTwoFactor = true, and the next login attempt skips the password check screen.
All four have the same cause. The code stores facts one at a time, but the screen needs to know which single situation it is in. A state machine stores that situation directly: one value, picked from a short, named list, that changes only in ways you wrote down. The rest of this lesson builds that idea from zero, then applies it to wizards, uploads, login flows, and media players.
The mental model: states, events, transitions
A finite state machine (FSM) is a description of a flow with three parts. It is "finite" because each part is a short list you can write down in full before the code runs.
- States: the situations the flow can be in, such as
idle,uploading,processing,ready, andfailed. The flow is in exactly one state at any moment. Never zero, never two. - Events: things that happen, such as
SELECT_FILE,PROGRESS,UPLOADED,SCAN_OK,ERROR,CANCEL, andRETRY. They come from the user, the network, timers, or the browser. - Transitions: rules of the form "in state A, event E moves the flow to state B". If there is no rule for an event in the current state, the event is ignored.
That last line does most of the work. A CANCEL that arrives while the flow is idle doesn't need an if to guard it: there is simply no arrow for it, so nothing happens. A late SCAN_OK for the first file finds the flow in uploading with the new file, where SCAN_OK has no arrow. The bug from the first chapter can't happen.
An everyday machine: the turnstile
A subway turnstile has two states, locked and unlocked, and two events, coin and push. In locked, a coin moves it to unlocked and a push does nothing. In unlocked, a push lets one person through and moves it back to locked, and another coin does nothing (the machine keeps it). Two states, two events, four rules, and the turnstile never lets two people through for one coin.
Your UI flows work the same way. The difference is that you get to name the states after what the user sees, and the machine becomes the one place that answers "what is on screen right now, and what can the user do next?"
The upload as a machine
Here is the avatar upload from the first chapter, drawn as states and arrows. The states mirror the ones a file record moves through on the server in the file uploads lesson (pending, uploading, processing, ready, rejected), seen from the browser's side. The client folds pending into the start of uploading, and it shows failed for both a network error and a server rejection, with the reason kept alongside.
Read the diagram as a contract. From uploading, three things can happen: the bytes finish and the server starts scanning (processing), the network fails (failed), or the user cancels (idle). There is no arrow from failed to ready, so no late response can ever paint a green tick over an error. If product later asks "what happens if the user cancels while we scan?", the diagram shows the missing arrow and you decide on purpose.
The same machine as a table
A diagram is good for people; a transition table is good for code and reviews. Rows are states, columns are events, and each cell is the next state. A dash means "ignored". Reviewing a table cell by cell is how teams find the questions nobody asked.
| State | SELECT_FILE | CANCEL | UPLOADED | SCAN_OK | ERROR | RETRY |
|---|---|---|---|---|---|---|
idle | uploading | - | - | - | - | - |
uploading | - | idle | processing | - | failed | - |
processing | - | - | - | ready | failed | - |
ready | uploading | - | - | - | - | - |
failed | uploading | idle | - | - | - | uploading |
Compare that with the boolean version. Four flags gave 16 combinations, most of them nonsense. Here there are exactly five states, and every one of them was chosen. Adding a state is a visible change in one place, not a new flag that every handler must now remember.
State is not all your data
A machine doesn't replace every variable. The upload percentage, the file name, and the error message are data that changes inside a state.
In statechart language they are context (also called extended state). The finite state answers "which situation?", and the context holds the numbers and strings for it. Chapter 4 covers context in detail.
Check your understanding · Exercise 1
You are modelling a search box with suggestions. Which of these belong in the finite state list (not in context)? Select all that apply.
Writing a machine in plain TypeScript
You don't need a library to get most of the benefit. Two TypeScript features and one React hook are enough: a discriminated union for the state, another one for the events, and a reducer, a pure function that takes the current state and an event and returns the next state.
Step 1: the state as a discriminated union
A discriminated union is a type made of several object shapes that share one literal field, here status. TypeScript narrows the type when you check that field, so inside if (state.status === "failed") it knows state.reason exists.
type UploadState =
| { status: "idle" }
| { status: "uploading"; file: File; percent: number }
| { status: "processing"; file: File; uploadId: string }
| { status: "ready"; url: string }
| { status: "failed"; file: File; reason: string };
type UploadEvent =
| { type: "SELECT_FILE"; file: File }
| { type: "PROGRESS"; percent: number }
| { type: "UPLOADED"; uploadId: string }
| { type: "SCAN_OK"; url: string }
| { type: "ERROR"; reason: string }
| { type: "CANCEL" }
| { type: "RETRY" };
Look at where the data sits. reason exists only in failed and url only in ready, so no screen can show an error message next to a finished avatar. percent exists only in uploading, so a progress bar can't show 100% on a failed upload. Richard Feldman's elm-conf 2016 talk "Making Impossible States Impossible" gave this idea its name: shape the type so the bad combinations can't even be written.
Step 2: the reducer is the transition table
The reducer switches on the current state first, then on the event. Any event a state doesn't handle falls through to return state, which is the dash in the table. Because states are the outer switch, you read the code the same way you read the diagram: "in uploading, these four events matter".
function upload(state: UploadState, event: UploadEvent): UploadState {
switch (state.status) {
case "idle":
case "ready":
if (event.type === "SELECT_FILE") return { status: "uploading", file: event.file, percent: 0 };
return state;
case "uploading":
switch (event.type) {
case "PROGRESS": return { ...state, percent: event.percent };
case "UPLOADED": return { status: "processing", file: state.file, uploadId: event.uploadId };
case "ERROR": return { status: "failed", file: state.file, reason: event.reason };
case "CANCEL": return { status: "idle" };
default: return state;
}
case "processing":
if (event.type === "SCAN_OK") return { status: "ready", url: event.url };
if (event.type === "ERROR") return { status: "failed", file: state.file, reason: event.reason };
return state;
case "failed":
if (event.type === "RETRY") return { status: "uploading", file: state.file, percent: 0 };
if (event.type === "SELECT_FILE") return { status: "uploading", file: event.file, percent: 0 };
if (event.type === "CANCEL") return { status: "idle" };
return state;
}
}
Note how RETRY works: failed kept the file, so retrying needs no extra variable and can't retry a file that was never chosen. With booleans, "retry" usually means reading a lastFile ref that might be stale or null.
Step 3: wiring it to React
useReducer(upload, { status: "idle" }) gives you state and dispatch. Event handlers and network callbacks only ever call dispatch with an event. They never set state directly, and they don't need to check what state the flow is in: the reducer decides.
const [state, dispatch] = useReducer(upload, { status: "idle" });
switch (state.status) {
case "idle":
return <FilePicker onPick={(file) => dispatch({ type: "SELECT_FILE", file })} />;
case "uploading":
return <Progress value={state.percent} onCancel={() => dispatch({ type: "CANCEL" })} />;
case "processing":
return <p role="status">Checking {state.file.name}...</p>;
case "ready":
return <img src={state.url} alt="Your new avatar" />;
case "failed":
return <ErrorBox reason={state.reason} onRetry={() => dispatch({ type: "RETRY" })} />;
}
Add an exhaustiveness check so the compiler helps when someone adds a state later. In a default branch, assign the value to a variable of type never: if a new status appears in the union and isn't handled, TypeScript reports an error at that line. Turning on the switch-exhaustiveness-check rule in typescript-eslint does the same across the codebase.
Step 4: test it without a browser
Because the reducer is a pure function, its tests are plain function calls. No rendering, no mock server, no timers. The race from chapter 1 becomes a three-line test that runs in about a millisecond with node --test.
import { test } from "node:test";
import assert from "node:assert/strict";
test("a late scan result for the old file is ignored", () => {
let s = upload({ status: "processing", file: oldFile, uploadId: "u1" }, { type: "ERROR", reason: "Scan timed out" });
s = upload(s, { type: "SELECT_FILE", file: newFile });
s = upload(s, { type: "SCAN_OK", url: "/old.png" });
assert.deepEqual(s, { status: "uploading", file: newFile, percent: 0 });
});
Check your understanding · Exercise 2
A teammate's reducer for a data fetch has case "SUCCESS": return { status: "success", data: event.data }; at the top level, outside any per-state switch. What can go wrong? Select all that apply.
Guards, actions, context, and effects
Real flows need four more ideas on top of states and events. They come from statecharts (next chapter) and every machine library uses the same words, so they are worth learning precisely.
Guards: a transition that only happens if
A guard is a condition attached to a transition. The arrow is taken only if the guard returns true; otherwise the event is ignored, or a different arrow for the same event is tried. Guards are pure functions of the context and the event: they must not fetch, write, or depend on the clock.
SELECT_FILEgoes touploadingonly iffile.sizeis at most 5 MB. A second arrow for the same event, with the opposite guard, goes straight tofailedwith the reason "File is larger than 5 MB".RETRYgoes touploadingonly ifattempts < 3. After that the Retry button is gone because the machine no longer accepts the event.NEXTin a wizard goes forward only if the current step's fields are valid.
A common mistake is to put the condition in the button's click handler instead (if (attempts < 3) dispatch(RETRY)). It works until a keyboard shortcut, a second button, or a test sends the same event without the check. Put the rule on the transition, and every sender gets it.
Context: the data that rides along
Context is the extended state: values like attempts, percent, file, and errorMessage that change without changing which finite state you are in. A PROGRESS event updates percent and stays in uploading. In the TypeScript version, context is the extra fields on each union member; in XState it is one context object that actions update with assign.
The rule of thumb: if a value changes what the user can do, it is probably a state. If it only changes what a state displays, it is context. "Uploading at 40%" and "uploading at 41%" allow the same events, so percent is context. "Can retry" versus "out of retries" allow different events, which is why it is a guard on attempts or its own state.
Actions: fire-and-forget side effects
An action is a quick side effect that runs on a transition, or when entering or leaving a state. Examples: send an analytics event on UPLOADED, move focus to the error message on entering failed, show a toast on entering ready. Actions don't decide anything and the machine doesn't wait for them.
- Transition actions run when one specific arrow is taken:
RETRY / incrementAttempts. - Entry actions run every time a state is entered, whichever arrow led there: entering
failedalways announces the error. - Exit actions run every time a state is left: leaving
uploadingalways hides the "Leave page?" warning.
Effects that live as long as a state
The upload itself isn't a quick action: it runs for seconds, sends progress, and must stop if the user cancels. Statecharts handle this with an invoked activity (XState calls it an invoked actor): work that starts when a state is entered and is stopped automatically when the state is left, by any arrow. That one rule removes a whole class of leaks, because no handler has to remember to clean up.
In a hand-rolled reducer the reducer stays pure, and an effect keyed on the state plays the invoked role. React's cleanup function is the "stop on exit" part. Note the upload uses XMLHttpRequest, because fetch still has no upload progress events in browsers.
const uploadingFile = state.status === "uploading" ? state.file : null;
useEffect(() => {
if (!uploadingFile) return;
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) =>
dispatch({ type: "PROGRESS", percent: Math.round((e.loaded / e.total) * 100) });
xhr.onload = () =>
xhr.status < 300
? dispatch({ type: "UPLOADED", uploadId: JSON.parse(xhr.responseText).id })
: dispatch({ type: "ERROR", reason: `Server said ${xhr.status}` });
xhr.onerror = () => dispatch({ type: "ERROR", reason: "Network error" });
xhr.open("POST", "/api/uploads");
xhr.send(uploadingFile);
return () => xhr.abort(); // leaving "uploading" by any arrow stops the request
}, [uploadingFile]);
Delayed transitions: time is an event too
Timers are events that the machine schedules for itself. "Stay in ready for 3 seconds, then go back to idle" is a delayed transition. "If processing takes more than 60 seconds, go to failed with 'Still checking, try again later'" is a timeout. Tied to a state, the timer is canceled automatically when the state is left, so a fast SCAN_OK never races a stale timeout.
Think first
Should "out of retries" be a guard on RETRY (attempts < 3) or a separate state like failedFinal?
Both work, and the deciding question is whether the screen changes. If the only difference is that the Retry button disappears, a guard plus snapshot.can (or a check of attempts when rendering) is enough and keeps the chart small.
If running out of retries shows a different screen, such as "Contact support" with a support code, make it a state. A state appears on the diagram and in the tests; a guard hides inside a transition. When in doubt, ask whether a designer would draw it as a separate mock-up.
Statecharts: nested and parallel states
Flat machines have a weakness: they grow fast. Add a "Cancel" that works from five states and you draw five arrows. Add a mute button to a video player and every state needs a muted twin.
In 1987 David Harel published "Statecharts: A visual formalism for complex systems", which added a few ideas to state machines so that big ones stay readable. XState, the W3C's SCXML format, and most other statechart tools today follow his ideas.
Nested states: share arrows with a parent
A compound state (or parent state) contains child states. When the flow is in a child, it is also in the parent. An arrow drawn from the parent applies to every child, so one CANCEL arrow from a busy parent covers uploading and processing together.
Logged-in apps show this well. A signedIn parent holds active, idleWarning ("you will be signed out in 60 seconds"), and reauthenticating. One LOGOUT arrow and one SESSION_EXPIRED arrow on the parent leave whichever child you are in. Without nesting you would copy both arrows onto three states, and the next state someone adds would probably miss one.
Parallel states: independent parts at the same time
A parallel state has several regions that are all active at once, each with its own current child. A video player is the classic case.
Playback can be playing, paused, or buffering. Sound can be muted or unmuted. Screen mode can be inline or fullscreen. These don't depend on each other: muting doesn't pause.
Flattening that into one machine means one state per combination: 3 x 2 x 2 = 12 states, each with copies of the same arrows. As three parallel regions it is 3 + 2 + 2 = 7 states. The parts that multiply in a flat machine only add up in a statechart. This is the main reason statecharts scale where flat machines don't.
Parallel regions are also an honest check on your design. If two regions keep needing to look at each other ("you can only unmute while playing"), they may not be independent after all, and a guard or a different split is better.
History and final states
- History state: remembers the last child a parent was in. A checkout wizard where the user opens "Edit address" and comes back should return to step 3, not step 1. Entering the parent through its history state does that.
- Final state: marks that a parent is finished. When the payment sub-flow reaches its final
paidstate, the parent wizard gets a "done" event and moves toconfirmation. It lets you build a flow out of smaller flows. - Initial state: every compound state names which child to start in, so entering
signedInalways lands inactive.
| Idea | What it solves | UI example |
|---|---|---|
| Nested states | The same arrow copied onto many states | LOGOUT from anywhere inside signedIn |
| Parallel states | Combinations multiplying into many states | Playback, sound, and fullscreen in a video player |
| History | Coming back to where you were in a sub-flow | Return to wizard step 3 after editing the address |
| Final states | Knowing when a sub-flow is done | Payment step finishes, wizard moves on |
| Delayed transitions | Timers that must die with their state | Hide the success tick after 3 seconds |
Check your understanding · Exercise 3
A chat composer has three independent parts: attachment (none, uploading, attached), emoji picker (closed, open), and send status (ready, sending). Which statements are true? Select all that apply.
XState v5 or a hand-rolled reducer
The reducer from chapter 3 covers flat machines well. Once you want nesting, parallel regions, timers that die with their state, and invoked work that is stopped for you, writing it by hand gets long. XState is the most widely used JavaScript library for statecharts. Version 5, released at the end of 2023, is the current major version, and it changed enough from v4 that old blog posts often show the wrong API.
The upload machine in XState v5
In v5 you describe the types, named actions, guards, and actors once with setup(...), then call .createMachine(...) on the result with the states. Transitions refer to guards and actors by name, which keeps the chart readable and makes them easy to swap in tests.
import { setup, assign, fromPromise } from "xstate";
export const uploadMachine = setup({
types: {
context: {} as { file: File | null; attempts: number; error: string | null },
events: {} as { type: "SELECT_FILE"; file: File } | { type: "CANCEL" } | { type: "RETRY" },
},
actors: {
sendFile: fromPromise<{ id: string }, { file: File }>(({ input, signal }) => postFile(input.file, signal)),
},
guards: { canRetry: ({ context }) => context.attempts < 3 },
}).createMachine({
id: "upload",
initial: "idle",
context: { file: null, attempts: 0, error: null },
states: {
idle: { on: { SELECT_FILE: { target: "uploading", actions: assign({ file: ({ event }) => event.file, attempts: 0 }) } } },
uploading: {
invoke: {
src: "sendFile",
input: ({ context }) => ({ file: context.file! }),
onDone: { target: "processing" },
onError: { target: "failed", actions: assign({ error: ({ event }) => String(event.error) }) },
},
on: { CANCEL: { target: "idle" } },
},
processing: { after: { 60000: { target: "failed", actions: assign({ error: "Still checking, try again later" }) } } },
ready: { after: { 3000: { target: "idle" } } },
failed: { on: { RETRY: { guard: "canRetry", target: "uploading", actions: assign({ attempts: ({ context }) => context.attempts + 1 }) }, CANCEL: { target: "idle" } } },
},
});
Three things happen here that the hand-rolled version did by convention. The invoke starts sendFile on entering uploading and stops it on leaving, and fromPromise passes a signal that is aborted when the actor stops, so CANCEL really cancels the request. The after timers are cleared if the state is left early. And guard: "canRetry" makes RETRY a no-op after three retries, for every sender.
In v4 the guard key was called cond, which is one of the most common copy-paste errors when following old examples.
Actors: running machines
A machine is only a description. To run it you create an actor: const actor = createActor(uploadMachine), then actor.subscribe(...) to watch it, actor.start(), and actor.send({ type: "CANCEL" }) to talk to it. v5 is built around the actor model: a running machine is an actor, the things it invokes (promises, callbacks, observables, other machines) are child actors, and they talk only by sending events.
In React, useMachine(uploadMachine) from @xstate/react returns [snapshot, send]. You render from snapshot.value or snapshot.matches("uploading"), read snapshot.context.attempts, and ask snapshot.can({ type: "RETRY" }) to decide whether to show a button. That last call runs the guards, so the UI and the machine can't disagree about what is allowed.
Decision How should this flow's machine be built?
What we pick: Start with a reducer for flat flows of up to about six states, which covers most screens. Reach for XState when the flow has nested or parallel parts, several timers, or child processes to coordinate, or when product and design want to review the chart itself. Use machine-based widget libraries for widgets either way.
Modelling wizards and uploads
The next two chapters model four flows that come up again and again in products and interviews. The method is the same each time: list what the user can see, name those as states, list what can happen, and draw the arrows. Then look for the missing ones.
Multi-step wizards
Take a three-step checkout: address, shipping, payment, then a review screen that submits. The obvious model is a number, step = 2. It breaks as soon as the steps stop being a straight line: a customer who picks up in store skips shipping, the review screen has "Edit" links back to any step, and "Place order" needs submitting and submitError situations that aren't steps at all.
- One state per step, plus
submitting,submitError, and a finalplaced. All the form data lives in context, so going back never loses what was typed. NEXTis guarded by the step's validation. A second arrow forNEXTwith a guard likeisPickupjumps from address straight to payment. The branching rule lives in one place instead of in each step's button.EDIT_STEPfrom review goes to the chosen step, and that step'sNEXTreturns to review rather than marching through every step again. A small piece of context,returnTo: "review", or a history state handles this.submittingignoresNEXT,BACK, andEDIT_STEP, which is the double-submit fix: the Place order button can't send twice because the second click has no arrow to follow.
Keep the current step in the URL too (/checkout/payment), as the URL-as-state lesson recommends, so refresh and the browser's Back button work. The machine then guards the URL: when the route says payment but the address step isn't valid, the machine refuses that transition and the app redirects to address. The URL says where the user wants to be; the machine says whether they can be there.
Uploads, from one file to many
The single-file machine from chapter 3 grows in three directions in real products. Each one is a small change to the chart rather than a new flag.
- Many files: give each file its own machine (in XState, spawn one child actor per file). A parent machine holds the list and limits concurrency: new files start in a
queuedstate, and the parent moves a file touploadingonly while fewer than 3 are running. The parent's own state, such asallDoneorsomeFailed, is derived from the children. - Pause and resume: resumable protocols like tus let an upload continue from the last byte the server received. Add a
pausedstate withPAUSEandRESUMEarrows, and keep the byte offset in context. - Going offline: the browser fires
offlineandonlineonwindow. Send them to the machine:uploadingplusOFFLINEgoes towaitingForNetwork, andONLINEresumes. Without this state the upload sits at 63% with no explanation.
The client states line up with the server's record from the file uploads lesson: the server's uploading, processing, and ready appear here too, and its rejected (the scan found malware, or the type was wrong) arrives as an ERROR with a reason and lands in failed. Keep the two vocabularies close so that a support engineer reading a log and a developer reading the chart mean the same thing by "processing".
SELECT_FILE in idle; the booleans accept everything, and red flags mean the screen is showing parts that should never appear together.Both versions start empty. Send an event, or replay a sequence.
Try the lab with the same sequence of events on both sides. The boolean version accepts every event and sets flags; the machine only follows arrows that exist. Send ERROR then SELECT_FILE, or CANCEL then a late SCAN_OK, and watch which side shows a screen nobody designed. The third replay button is the bug report from chapter 1, and it reaches two impossible screens.
Check your understanding · Exercise 4
In the checkout wizard, a user double-clicks "Place order" and the API receives two orders. Which fixes belong in the machine? Select all that apply.
Modelling auth flows and media players
Sign-in flows
Authentication is a flow with many branches: wrong password, two-factor codes, locked accounts, expired sessions, and sign-out from another tab. Boolean soup here is not only ugly but risky, because a wrong combination can show private pages to a signed-out user for a moment, or skip a step.
checkingSessionis the initial state on page load, while the app asks the server whether the session cookie is still valid. Render a neutral skeleton here. Starting insignedOutinstead is what causes the familiar flash of the login page before the dashboard appears.signedOutacceptsSUBMITand moves tosigningIn, which ignores a secondSUBMIT.- From
signingIn, the server's answer picks the arrow:SUCCESStosignedIn,MFA_REQUIREDtoneedsMfa,INVALIDback tosignedOutwith a message in context, andLOCKEDtolocked, which has noSUBMITarrow at all. signedInis a parent with childrenactive,refreshing(a new access token is being fetched), andidleWarning.LOGOUTandSESSION_EXPIREDon the parent leave from any child.
The refreshing child solves a classic race. Five API calls fail with 401 at the same moment, because the access token expired. With flags, each one starts its own token refresh, and four of them use a refresh token the first one just rotated, which signs the user out, as the auth tokens lesson explains.
With a machine, the first TOKEN_EXPIRED moves active to refreshing; the other four arrive in refreshing, where the event has no arrow, and their requests wait for REFRESHED. One refresh, then the calls are retried. The machine lives in one tab, so several open tabs still need a lock (such as the Web Locks API) to share one refresh.
Remember that the machine controls the UI, not security. The server must still check every request. Hiding the dashboard in signedOut is a user experience choice; the API returning 401 is the actual protection. Signing out in another tab arrives as an event from a storage or BroadcastChannel listener, which the cross-tab sync lesson covers.
Media players
A video player looks simple: a play button and a pause button. The trouble is that the user's click and the video element's reality are two different things. Asking to play starts a request: frames may take two seconds to arrive, or never, and if the page starts the video on its own, the browser may refuse because of its autoplay policy.
So the playback region needs more states than "playing" and "paused", and its events should come from the video element, not from the button.
| State | Entered on | Button shows |
|---|---|---|
idle | page load, before anyone asks to play | Play |
loading | a PLAY before any frames, or a seeking event | Spinner, and Pause still works |
playing | the element's playing event | Pause |
buffering | the element's waiting event while playing | Spinner over the frame |
paused | the user's PAUSE, confirmed by the element's pause event | Play |
blocked | video.play() rejecting with NotAllowedError (autoplay blocked) | A large "Tap to play" |
ended | the element's ended event | Replay |
failed | the element's error event | An error message with Retry |
video.play() returns a promise. Browsers reject it with a NotAllowedError when the page tries to play with sound before the user has interacted with it, which is common on first visits. Without a blocked state, an autoplaying player shows "Pause" while nothing plays, and stays wrong. With the machine, PLAY goes to loading, and only the element's playing event moves on to playing.
The other parts of the player, sound (muted, unmuted) and screen mode (inline, fullscreen), are parallel regions, as the statechart in chapter 5 showed. Try the simulator below: the three regions change independently, and the playback region refuses events that make no sense in its current state.
blocked or idle.All three regions start in their initial states: idle, unmuted, inline.
Check your understanding · Exercise 5
Users report that the play button sometimes shows "Pause" while the video is frozen. Which machine changes fix it? Select all that apply.
Visualizing and testing machines
A machine is a small, finite object, and that is its biggest advantage after correctness: you can draw all of it and test all of it. A component with six booleans can't be listed in full; a machine with six states and eight events can, in a 48-cell table.
Draw it where people already look
- In docs and pull requests: Mermaid's
stateDiagram-v2renders in GitHub Markdown, so a chart can sit next to the code and be reviewed with it. - With Stately's tools: Stately Studio is a visual editor for XState machines, and the Stately Inspector shows a running machine's current state and every event it receives.
- In the app itself: log each transition (old state, event, new state) into your error tracker's breadcrumbs. When a bug report arrives, the last ten transitions usually explain it.
stateDiagram-v2
[*] --> idle
idle --> uploading: SELECT_FILE
uploading --> processing: UPLOADED
uploading --> failed: ERROR
uploading --> idle: CANCEL
processing --> ready: SCAN_OK
processing --> failed: ERROR
failed --> uploading: RETRY
failed --> uploading: SELECT_FILE
failed --> idle: CANCEL
ready --> uploading: SELECT_FILE
Test every transition, including the ignored ones
Most tests check the happy path. A machine lets you go further cheaply: loop over every state and every event, and assert the next state. The cells you expect to be ignored matter as much as the arrows, because "a late SCAN_OK while uploading a new file is ignored" is exactly the bug from chapter 1.
const expected: Record<Status, Partial<Record<EventType, Status>>> = {
idle: { SELECT_FILE: "uploading" },
uploading: { PROGRESS: "uploading", UPLOADED: "processing", ERROR: "failed", CANCEL: "idle" },
processing: { SCAN_OK: "ready", ERROR: "failed" },
ready: { SELECT_FILE: "uploading" },
failed: { RETRY: "uploading", SELECT_FILE: "uploading", CANCEL: "idle" },
};
for (const status of statuses) {
for (const event of sampleEvents) {
test(`${status} + ${event.type}`, () => {
const next = upload(sampleState(status), event);
assert.equal(next.status, expected[status][event.type] ?? status);
});
}
}
Paths: reaching every state
Transition tests check one step at a time. Path tests check that every state can be reached from the start, and what the screen shows there. Because the machine is a graph, a program can list the shortest sequence of events to each state, then a component test or an end-to-end test replays each sequence and checks the screen. XState's getShortestPaths function, imported from xstate/graph (it used to be the separate @xstate/graph package), generates these paths from a machine; for a hand-rolled reducer, a breadth-first search over the table is about 20 lines.
This also finds design bugs. A state no path reaches is dead code. A state with no way out (no outgoing arrow and not marked final) is a trap: the user is stuck until they reload. Both show up immediately in a generated path list, and almost never in hand-written tests.
| Level | What it checks | Speed |
|---|---|---|
| Transition tests | Every state and event pair, arrows and ignored cells | Milliseconds, pure functions |
| Path tests on the component | Each state's screen, reached by its shortest event path | Seconds, with Testing Library |
| End-to-end tests | Two or three critical paths against a real browser and server | Tens of seconds each |
Think first
Your machine has 100% line coverage from five happy-path tests. Is it well tested?
Probably not. Line coverage counts code that ran. Each state's return state line runs for the first ignored event you send to that state, after which every other ignored cell in that state looks covered too.
Transition coverage is the better measure for machines: of all state and event pairs, how many did a test check? A table-driven test like the one above gets 100% of it in one loop, and it is the ignored cells that catch late responses and double submits.
When a machine is overkill, and how to use it in interviews
Machines are a tool for flows, not a rule for every piece of state. Modelling a dropdown's open flag as a statechart adds a file, a dependency, and a concept for no gain: a boolean with two values already is a two-state machine.
Skip the machine when
- The state is one independent value: open or closed, a selected tab, a sort order. A boolean, a string union, or the URL already says it.
- It is server data: a cache library already tracks
pending,error, andsuccessper query, plus retries and refetching. Don't rebuild that by hand. - It is form field values: a form library or plain inputs hold them. A machine may still drive the form's submit flow around them (
editing,submitting,submitted), which the forms lesson covers. - It can be derived:
isEmptyfromitems.length === 0is a calculation, not a state. Storing it creates one more thing to keep in sync.
Reach for one when
- Two or more flags interact and some combinations must never happen.
- There is async work the user can cancel, retry, or race (uploads, search, sign-in).
- The flow has steps, branches, or a "come back to where I was" (wizards, onboarding).
- Bugs are reported as "it got stuck", "it showed both", or "it happened twice".
- Product, design, and QA need to agree on behavior: a chart is a spec all three can read.
The cost is real but small: a level of indirection (handlers send events instead of setting values), and a habit the team has to learn. A hand-rolled reducer costs nothing at runtime. XState adds bundle weight and its own concepts, which is why it pays off on complex flows and not on simple ones.
Bringing it up in a system design interview
Interviewers rarely ask "do you know state machines?". They ask you to design an upload widget, a video player, a checkout, or a chat composer, and they watch how you handle the in-between moments. A small chart drawn early is one of the clearest signals of care you can give.
- After requirements, say: "Let me list the states this component can be in, so we agree on behavior before data." Draw four to seven boxes.
- Draw the arrows with event names, and ask about one missing arrow out loud: "What should happen if the user cancels while we are scanning?" This shows you look for edge cases.
- In the data model section, write the state as a discriminated union and point out the data that only exists in one state.
- When you reach failures, show that late responses and double clicks are ignored by design, and where requests get aborted.
| Level | What a strong answer shows |
|---|---|
| Junior | A status union instead of booleans, one screen per status, and buttons disabled from the status |
| Mid | A drawn chart with events, guards for retries and validation, cancellation that aborts requests, and table-driven tests |
| Senior | Nested and parallel states where they cut size, one actor per item for lists, mapping to server states, and when not to use a machine |
| Staff | Machines as a shared spec across teams, transition logs in observability, and a library choice weighed against bundle and team cost |
Check your understanding · Exercise 6
Which of these are good candidates for a state machine? Select all that apply.
Summary
- Boolean flags multiply: four give 16 combinations for a flow with five real situations. The rest are impossible states waiting to be shown.
- A state machine keeps one current state from a finite list; events move it only along arrows you wrote, and every other event is ignored.
- In TypeScript, a discriminated union on
statusplus a reducer that switches on state first is a complete, dependency-free machine. Put each piece of data only in the state where it means something. - Guards decide whether an arrow is taken, actions are quick side effects, context holds the data, and long-running work should start on entering a state and stop on leaving it.
- Statecharts (Harel, 1987) add nested and parallel states, history, and timers, so big flows add up instead of multiplying. XState v5 implements them with
setup,createMachine, and actors. - Model wizards, uploads, sign-in, and media players by asking what the user sees while waiting: those answers are your missing states.
- Test every state and event pair from a table, and generate paths to every state to find dead and stuck states.
- Skip the machine for single independent values, server data, and derived values.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Design the state for a file upload widget. How do you avoid showing an error and a success at the same time?
I'd replace separate flags with one status: idle, uploading, processing, ready, failed, as a discriminated union in TypeScript. percent exists only in uploading, the URL only in ready, and the reason only in failed, so the bad combinations can't even be written.
A reducer switches on the current state first, then on the event. Late events have no arrow in the states where they don't belong, so an UPLOADED after CANCEL is ignored. The request itself is aborted when the state leaves uploading, with an AbortController or xhr.abort(), and I'd test every state and event pair from a table.
Question 2
What do statecharts add to plain finite state machines, and when do you need them?
Harel's statecharts add nested states, parallel regions, history, final states, and delayed transitions. Nesting lets one arrow on a parent, like LOGOUT on signedIn, apply to every child. Parallel regions keep independent parts separate, so a player's playback, sound, and fullscreen are 3 + 2 + 2 = 7 states instead of 3 x 2 x 2 = 12.
I need them when a flat machine starts copying the same arrows onto many states, or when combinations multiply. For a flat flow of a handful of states, a reducer is enough.
Question 3
When would you use XState instead of a useReducer machine?
A reducer with a union covers flat flows with no dependency and is trivial to test. I move to XState v5 when the flow needs nesting, parallel states, several timers, or invoked async work that must be stopped when a state is left, because XState does that cleanup for me: invoked actors stop on exit, and fromPromise gets an abort signal.
It also helps when the chart itself is a shared artifact with design and QA, through Stately's editor and inspector. The costs are bundle size and a learning curve, so I wouldn't use it for a dropdown.
Question 4
Model a video player's play button. Why does it sometimes show "Pause" while nothing plays, and how would a machine fix it?
The bug comes from storing the user's intent as the truth: the click sets isPlaying, but the video may be loading, buffering, or blocked by the autoplay policy. I'd drive the playback region from the element's own events: PLAY moves to loading, the playing event to playing, waiting to buffering, and a rejected play() promise with NotAllowedError to blocked.
The button label is then derived from the state. Sound and fullscreen are parallel regions so they don't multiply the playback states.
Question 5
Five API calls get a 401 at the same time because the access token expired. How does a state machine help?
Without one, each call starts its own refresh, and with rotating refresh tokens the later refreshes fail and sign the user out. With a signedIn parent holding active and refreshing, the first TOKEN_EXPIRED moves to refreshing and starts one refresh. The other four events arrive in refreshing, where there is no arrow, so their requests wait for REFRESHED and are retried once.
The machine only coordinates the client. The server still enforces auth on every request.
Practice it
These design problems lean on state machines for ui.
- Design a countdown timerEasy
- Design a file uploaderMedium
- Design a throttled progress bar queueMedium
- Design a multi-step form wizardMedium
- Design an Instagram-style stories viewerMedium
- Design a customer support chat widgetMedium
- Design an e-commerce cart and checkoutMedium
- Design sign up and sign inMedium
- Design a client-side rate-limited action queueMedium
- Design a food delivery appMedium
- Design a grocery delivery slot pickerMedium
- Design a QR code event check-in appMedium
- Design a dropdown menuMedium
- Design an online chess gameMedium
- Design a gamified learning app like DuolingoMedium
- Design an e-signature workflow like DocuSignMedium
- Design a scheduling link like CalendlyMedium
- Design an online exam platformMedium
- Design an AI image generation studioMedium
- Design a rich text editorHard
- Design a video playerHard
- Design a checkout flowHard
- Design a secure payment formHard
- Design undo and redoHard
- Design a multiplayer trivia gameHard
- Design a TikTok-style vertical video feedHard
- Design YouTubeHard
- Design NetflixHard
- Design the Spotify web playerHard
- Design a flight booking siteHard
- Design a seat selection map for eventsHard
- Design Uber's rider web appHard
- Design Trello or JiraHard
- Design an IoT dashboard for thousands of sensorsHard
- Design a browser-based music studioHard
- Design a video call web app like Google MeetHard
- Design a Twitch-style live stream pageHard
- Design a long insurance application formHard
- Design a browser video editorHard
- Design a stock trading appHard
- Design a 3D product configuratorHard
References
- XState documentation The official docs for XState v5: setup, machines, actors, guards, and delays.
- Promise actors in XState How
fromPromiseworks, including input, output, and the abort signal. - Graph and paths in XState
getShortestPathsandgetSimplePathsfromxstate/graph, for generating path tests. - Statecharts: A visual formalism for complex systems (David Harel, 1987) The original paper that introduced nested and parallel states.
- State Chart XML (SCXML), W3C Recommendation A standard format for statecharts, useful for the precise meaning of each feature.
- TypeScript handbook: Narrowing and discriminated unions How TypeScript narrows a union by its literal field, and the
neverexhaustiveness check. - HTMLMediaElement on MDN The media events (
playing,waiting,pause,ended,error) that drive a player machine. - Mermaid state diagrams The syntax for drawing machines in Markdown and pull requests.
Read next
- Kinds of stateServer data, UI toggles, the URL, form drafts, and live streams are five different kinds of state, each with its own home: knowing which is which is the first step of every frontend design.
- Local vs global stateWhere each piece of state lives decides what re-renders when it changes: keep it next to its readers, lift it only as far as needed, and use context or a store with selectors only for state that many distant parts share.
- Uploading filesHow a file gets from the user's device to your storage: through your server or straight to object storage with a pre-signed URL, in resumable chunks with honest progress, checked on both sides, and served back without opening a security hole.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.