cf.completefrontend
My progress
FUNDAMENTALS · MODULE 10

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.

A matrix of all 16 combinations of four flags: isUploading, isError, isDone, and isCanceled, in two columns of eight. Five rows are real situations: all off is idle, only uploading, only error is failed, only done, and only canceled. The other 11 rows, such as uploading and error together, are marked impossible.U = isUploading, E = isError, D = isDone, C = isCanceledUEDCidleoncanceledondoneononimpossibleonfailedononimpossibleononimpossibleonononimpossibleUEDConuploadingononimpossibleononimpossibleonononimpossibleononimpossibleonononimpossibleonononimpossibleononononimpossible5 real situations, 11 impossible ones
Four flags give 2 x 2 x 2 x 2 = 16 combinations. The upload only has 5 situations, so 11 combinations are states the code can reach but nobody designed.

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: isLoading and error and data can 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 isSubmitting is 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 isPlaying was set when the user pressed play, not when frames arrived.
  • Login: a user who closes the two-factor dialog is left with isLoggedIn = false and needsTwoFactor = 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, and failed. 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, and RETRY. 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.

The upload state machine. SELECT_FILE goes from idle to uploading. From uploading, UPLOADED goes to processing, ERROR goes to failed, and CANCEL goes back to idle. From processing, SCAN_OK goes to ready and ERROR goes to failed. From failed, RETRY goes to uploading and CANCEL goes to idle. A note says SELECT_FILE in ready or failed also starts a new upload.idleuploadingprocessingreadyfailedSELECT_FILECANCELUPLOADEDERRORRETRYSCAN_OKERRORCANCELSELECT_FILE in ready or failed also starts a new upload
Every arrow is one rule. There is no arrow from failed to ready and none for CANCEL while processing, so those events are ignored there unless you decide to add an arrow.

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.

Five states times six events is 30 cells. Only 10 are filled; the other 20 are events the flow deliberately ignores in that state. PROGRESS is left out because it only updates data inside uploading.
StateSELECT_FILECANCELUPLOADEDSCAN_OKERRORRETRY
idleuploading-----
uploading-idleprocessing-failed-
processing---readyfailed-
readyuploading-----
faileduploadingidle---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.

ts
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" };
Each piece of data lives only in the state where it means something.

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.

Before and after. Before: six separate fields, isUploading true, isError true, isDone false, percent 100, errorMessage Network, and an old url, which together describe a spinner, an error, and a full progress bar at once. After: one union value with status failed, the file me.png, and the reason Network error. A note says url and percent are not fields of the failed state at all.before: six separate fieldsisUploading: trueisError: trueisDone: falsepercent: 100errorMessage: "Network"url: "/avatar-old.png"after: one union valuestatus: "failed"file: me.pngreason: "Network error"url and percent are notfields of failed at all
On the left, nothing stops the fields from disagreeing. On the right, the type only lets you write data that belongs to the current status.

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".

ts
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;
  }
}
About 25 lines, no library, and every arrow in the diagram is one line here.

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.

tsx
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" })} />;
}
One switch renders one screen per state. There is no screen for a combination you didn't design.

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.

ts
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 });
});
The bug from the first chapter, written as a test that now passes by construction.

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.

One transition, annotated. An arrow from failed to uploading is labelled RETRY, with the guard attempts less than 3, and the action incrementAttempts. Notes point at its parts: the event is what happened, the guard must be true for the arrow to be taken, and the context holds attempts equal to 1. Below, the action runs on the way, failed has an entry action that announces the error, and uploading invokes sendFile, which is stopped on exit.eventwhat happenedguardmust be truecontextattempts: 1faileduploadingRETRY [attempts < 3]/ incrementAttemptsentry actionannounce the erroractionruns on the wayinvoke: sendFile,stopped on exit
Read an arrow as: event, then guard in square brackets, then actions after the slash. Entry actions and invoked work belong to states, not arrows.

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_FILE goes to uploading only if file.size is at most 5 MB. A second arrow for the same event, with the opposite guard, goes straight to failed with the reason "File is larger than 5 MB".
  • RETRY goes to uploading only if attempts < 3. After that the Retry button is gone because the machine no longer accepts the event.
  • NEXT in 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 failed always announces the error.
  • Exit actions run every time a state is left: leaving uploading always 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.

tsx
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]);
The effect starts when the state becomes uploading and aborts when it leaves, including on CANCEL.

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?

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.

A statechart for a video player: one parent state holding three parallel regions. The playback region has playing, buffering, and paused, with arrows both ways between playing and each of the others, and a note that its events come from the video element. The sound region has unmuted and muted. The screen region has inline and fullscreen. The current states are playing, unmuted, and inline.player: three parallel regionsplaybackplayingbufferingpausedevents come from the videosoundunmutedmutedscreeninlinefullscreen
The player is in one state per region at the same time, here playing, unmuted, and inline. Three regions need 3 + 2 + 2 = 7 states; one flat machine would need 3 x 2 x 2 = 12.

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 paid state, the parent wizard gets a "done" event and moves to confirmation. It lets you build a flow out of smaller flows.
  • Initial state: every compound state names which child to start in, so entering signedIn always lands in active.
IdeaWhat it solvesUI example
Nested statesThe same arrow copied onto many statesLOGOUT from anywhere inside signedIn
Parallel statesCombinations multiplying into many statesPlayback, sound, and fullscreen in a video player
HistoryComing back to where you were in a sub-flowReturn to wizard step 3 after editing the address
Final statesKnowing when a sub-flow is donePayment step finishes, wizard moves on
Delayed transitionsTimers that must die with their stateHide 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.

ts
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" } } },
  },
});
The SCAN_OK event that moves processing to ready is left out to keep the block short. postFile is your own function that sends the bytes and passes the signal on.

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?

Hand-rolled reducer with a discriminated union

useReducer plus a union for state and events

Effects keyed on the state for long-running work

Unit tests that call the reducer directly

Pros

  • No dependency and no new concepts for the team
  • Full TypeScript narrowing on every state's data
  • Pure function, trivial to test

Cons

  • Nesting, parallel regions, and history are manual work
  • Cleanup and timers depend on discipline
  • No visual editor or inspector

XState v5

setup(...).createMachine(...), run with createActor or useMachine

Invoked actors for async work

Visualize and inspect with Stately's tools

Pros

  • Full statecharts: nesting, parallel, history, delays, final states
  • Invoked work and timers are stopped automatically
  • The machine can be drawn, inspected, and shared with designers

Cons

  • A dependency that adds bundle weight, and a learning curve
  • Typing is good with setup but verbose
  • Easy to over-model simple screens

A component library built on machines

Use ready-made machines for widgets, for example Zag.js for menus, dialogs, and comboboxes

Write only your product flows yourself

Pros

  • Hard widget logic (focus, keyboard, open and close) is already modelled and tested
  • Works across frameworks

Cons

  • Only covers generic widgets, not your checkout or upload flow
  • Another set of APIs to learn

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.

A checkout wizard as a state machine. NEXT moves from address to shipping to payment to review. A second NEXT arrow with the guard isPickup jumps from address straight to payment. From review, PLACE_ORDER goes to submitting. From submitting, OK goes to the final placed state and FAILED goes to submitError, where RETRY goes back to submitting. Notes say EDIT_STEP from review goes to any step and NEXT then returns to review, and that submitting ignores NEXT, BACK, and EDIT_STEP.addressshippingpaymentreviewNEXTNEXTNEXTNEXT [isPickup]submittingsubmitErrorplacedPLACE_ORDEROKFAILEDRETRYEDIT_STEP from reviewgoes to any step, andNEXT then returnssubmitting ignores NEXT, BACK, and EDIT_STEP
Branches and the double-submit rule live on the arrows, not in each step's button. The final state tells the page the flow is done.
  • One state per step, plus submitting, submitError, and a final placed. All the form data lives in context, so going back never loses what was typed.
  • NEXT is guarded by the step's validation. A second arrow for NEXT with a guard like isPickup jumps from address straight to payment. The branching rule lives in one place instead of in each step's button.
  • EDIT_STEP from review goes to the chosen step, and that step's NEXT returns to review rather than marching through every step again. A small piece of context, returnTo: "review", or a history state handles this.
  • submitting ignores NEXT, BACK, and EDIT_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 queued state, and the parent moves a file to uploading only while fewer than 3 are running. The parent's own state, such as allDone or someFailed, 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 paused state with PAUSE and RESUME arrows, and keep the byte offset in context.
  • Going offline: the browser fires offline and online on window. Send them to the machine: uploading plus OFFLINE goes to waitingForNetwork, and ONLINE resumes. 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".

The upload lab. On the left, four boolean flags and a percent: isUploading false, isError false, isDone false, isCanceled false, percent 0. The boolean screen shows the file picker. On the right, a machine with five states, currently idle, showing file picker. Impossible screens reached by the booleans so far: 0.four booleansisUploadingfalseisErrorfalseisDonefalseisCanceledfalsepercent0%screen: file pickerimpossible screens: 0one machineidleuploading (0%)processingreadyfailedscreen: file pickerimpossible screens: 0
Both sides get the same events. The machine accepts only 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.

  • checkingSession is 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 in signedOut instead is what causes the familiar flash of the login page before the dashboard appears.
  • signedOut accepts SUBMIT and moves to signingIn, which ignores a second SUBMIT.
  • From signingIn, the server's answer picks the arrow: SUCCESS to signedIn, MFA_REQUIRED to needsMfa, INVALID back to signedOut with a message in context, and LOCKED to locked, which has no SUBMIT arrow at all.
  • signedIn is a parent with children active, refreshing (a new access token is being fetched), and idleWarning. LOGOUT and SESSION_EXPIRED on the parent leave from any child.
A sequence diagram with four lifelines: page, machine, API, and auth server. The page makes 5 API calls and gets 5 responses of 401. It sends TOKEN_EXPIRED to the machine, which posts to the auth server's refresh endpoint. The page sends 4 more TOKEN_EXPIRED events, which are ignored. The auth server replies with a new token, the machine sends REFRESHED to the page, and the page retries the 5 calls. A note says the first TOKEN_EXPIRED moves active to refreshing and the other 4 find no arrow.pagemachineAPIauth server5 API calls5 x 401TOKEN_EXPIREDPOST /refresh4 more, ignorednew tokenREFRESHED5 retriesfirst TOKEN_EXPIRED: active to refreshing,the other 4 find no arrow and are ignored
One refresh instead of five. The refreshing state turns a race between parallel requests into a queue, because the extra events have nowhere to go.

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.

Map each state to one element event. The button label is derived from the state, never stored.
StateEntered onButton shows
idlepage load, before anyone asks to playPlay
loadinga PLAY before any frames, or a seeking eventSpinner, and Pause still works
playingthe element's playing eventPause
bufferingthe element's waiting event while playingSpinner over the frame
pausedthe user's PAUSE, confirmed by the element's pause eventPlay
blockedvideo.play() rejecting with NotAllowedError (autoplay blocked)A large "Tap to play"
endedthe element's ended eventReplay
failedthe element's error eventAn 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.

A video player statechart with three parallel regions. Playback has idle, loading, playing, buffering, paused, blocked, ended, and failed, and is now idle. Sound is now unmuted. Screen is now inline. The play button shows Play.playbackidleloadingplayingbufferingpausedblockedendedfailedsoundunmutedmutedscreeninlinefullscreenthe play button showsPlay
Each region has exactly one current state. The button label is derived from playback, so it can't say Pause while the video is 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-v2 renders 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.
text
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
The upload machine in Mermaid. Paste it into a fenced mermaid block in any GitHub Markdown 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.

ts
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);
    });
  }
}
5 states x 7 events = 35 tests generated from one table, which doubles as the spec.
A tree of the shortest event paths from idle to every state of the upload machine. SELECT_FILE leads from idle to uploading. From uploading, UPLOADED leads to processing and ERROR leads to failed. From processing, SCAN_OK leads to ready. A note says the shortest path to ready is SELECT_FILE, UPLOADED, SCAN_OK.idleuploadingprocessingfailedreadySELECT_FILEUPLOADEDERRORSCAN_OKshortest path to ready:SELECT_FILE, UPLOADED,SCAN_OK
Each branch is one path test: replay its events, then check the screen. A state that never appears in the tree is dead; a state with no way out that isn't final is a trap.

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.

LevelWhat it checksSpeed
Transition testsEvery state and event pair, arrows and ignored cellsMilliseconds, pure functions
Path tests on the componentEach state's screen, reached by its shortest event pathSeconds, with Testing Library
End-to-end testsTwo or three critical paths against a real browser and serverTens of seconds each

Think first

Your machine has 100% line coverage from five happy-path tests. Is it well tested?

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.

A decision flowchart. Is it one independent value? Yes: useState. No: is it server data? Yes: a query library. No: do flags clash, or is there async work to cancel? No: plain state is fine. Yes: does it have nested or parallel parts? Yes: XState v5. No: a reducer with a union.one independent value?useStateserver data?query libraryflags that clash,or async work to cancel?plain statenested or parallel?XState v5reducer + unionyesnoyesnonoyesyesno
Most state never needs a machine. Reach for one when combinations clash or async work can be canceled, and for a library mainly when the chart has nested or parallel parts.

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, and success per 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: isEmpty from items.length === 0 is 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.

  1. 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.
  2. 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.
  3. In the data model section, write the state as a discriminated union and point out the data that only exists in one state.
  4. When you reach failures, show that late responses and double clicks are ignored by design, and where requests get aborted.
LevelWhat a strong answer shows
JuniorA status union instead of booleans, one screen per status, and buttons disabled from the status
MidA drawn chart with events, guards for retries and validation, cancellation that aborts requests, and table-driven tests
SeniorNested and parallel states where they cut size, one actor per item for lists, mapping to server states, and when not to use a machine
StaffMachines 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 status plus 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?

Question 2

What do statecharts add to plain finite state machines, and when do you need them?

Question 3

When would you use XState instead of a useReducer machine?

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?

Question 5

Five API calls get a 401 at the same time because the access token expired. How does a state machine help?

Practice it

These design problems lean on state machines for ui.

References

Read next