Skip to the chapter
←ReactGGroundwork⚛React
React/Advanced/A21
54 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛A21 · Part 3 · Advanced

State machines and XState

When a screen has modes, list the modes and the events each one accepts, instead of guessing from booleans.

5 min read9 sections2 exercises
54 of 57

In this chapter

  • The bug a machine prevents
  • Start without a library
  • XState v5
  • The parts a reducer does not give you
  • Where side effects go

and 4 more

about 5 min
09On this page
  1. The bug a machine prevents
  2. Start without a library
  3. XState v5
  4. The parts a reducer does not give you
  5. Where side effects go
  6. Mistakes worth avoiding
  7. Using it across components
  8. Where it earns its place, and where it does not
  9. Testing gets easier

The bug a machine prevents

const [isLoading, setLoading] = useState(false);
const [isError, setError] = useState(false);
const [isSuccess, setSuccess] = useState(false);
const [isPaused, setPaused] = useState(false);

Four booleans give sixteen combinations and the component handles perhaps five. The other eleven — loading and success, paused while in error — are reachable, unhandled, and found by users. A finite state machine replaces the flags with one value that is exactly one of a fixed list, plus a rule for which events move it where. If an event is not listed for the current state, nothing happens. The reducer chapter made this argument for one field; a machine is that idea made complete.

Start without a library

const machine = {
  idle:    { FETCH: "loading" },
  loading: { RESOLVE: "success", REJECT: "error" },
  success: { FETCH: "loading" },
  error:   { RETRY: "loading" },
};

function transition(state, event) {
  return machine[state]?.[event] ?? state;      // unknown event: stay put
}

const [state, dispatch] = useReducer(transition, "idle");
dispatch("FETCH");

That table is a complete machine. Read it as documentation: from loading you can only resolve or reject, so a second click on a "Fetch" button while loading is ignored by construction, not by an if (isLoading) return someone must remember. The same table is a type, too — the discriminated union from the TypeScript chapter is the compile-time half of the same idea.

XState v5

import { setup, assign, fromPromise } from "xstate";
import { useMachine } from "@xstate/react";

const userMachine = setup({
  types: {
    context: {} as { id: string; user: User | null; error: string | null },
    events: {} as { type: "FETCH"; id: string } | { type: "RETRY" },
  },
  actors: {
    loadUser: fromPromise(async ({ input }: { input: { id: string } }) => getUser(input.id)),
  },
}).createMachine({
  id: "user",
  initial: "idle",
  context: { id: "", user: null, error: null },
  states: {
    idle: {
      on: { FETCH: { target: "loading", actions: assign({ id: ({ event }) => event.id }) } },
    },
    loading: {
      invoke: {
        src: "loadUser",
        input: ({ context }) => ({ id: context.id }),
        onDone: { target: "success", actions: assign({ user: ({ event }) => event.output }) },
        onError: { target: "error", actions: assign({ error: ({ event }) => String(event.error) }) },
      },
    },
    success: { on: { FETCH: { target: "loading" } } },
    error: { on: { RETRY: "loading" } },
  },
});
function Profile() {
  const [snapshot, send] = useMachine(userMachine);

  if (snapshot.matches("loading")) return <Spinner />;
  if (snapshot.matches("error")) return <button onClick={() => send({ type: "RETRY" })}>Retry</button>;
  if (snapshot.matches("success")) return <p>{snapshot.context.user.name}</p>;
  return <button onClick={() => send({ type: "FETCH", id: "1" })}>Load</button>;
}

setup() declares the types and the named implementations first, so the machine definition stays readable and the events, context and actors are checked. invoke starts an actor when the state is entered and stops it when the state is left — leaving loading cancels the request's result, which is the race condition from data fetching solved structurally. Context holds the data that travels with the machine; state holds the mode. Keep them separate: "is a user loaded" is a state, "who" is context.

The parts a reducer does not give you

loading: {
  after: { 8000: "timeout" },                          // delayed transition
  on: {
    CANCEL: "idle",
    RESOLVE: { guard: ({ context }) => context.attempts < 3, target: "success" },
  },
}
  • Guards: a transition that only applies if a condition holds, declared beside the transition instead of buried in a handler.
  • Delayed transitions: after handles timeouts, debounces and auto-dismiss without a single setTimeout or cleanup to forget.
  • Hierarchical states: a submitting state can have its own validating and sending children, and an event handled on the parent applies to all of them.
  • Parallel states: a media player is playing and muted and fullscreen; each dimension is its own region rather than an explosion of combined names.
  • Reading it: snapshot.matches("loading") for a state, and snapshot.can({ type: "RETRY" }) to ask whether an event would do anything — ideal for disabling a button truthfully.

Where side effects go

states: {
  saving: {
    entry: "showSpinner",                         // runs on the way in
    exit: "hideSpinner",                          // runs on the way out, however it leaves
    invoke: { src: "save", onDone: "saved", onError: "failed" },
  },
}

The rule that keeps a machine testable: transitions and guards are pure, and effects live in three named places. entry and exit actions fire when a state is entered or left — and exit fires however the state is left, which makes it the reliable spot for cleanup. Actions on a transition run as it happens. invoke starts long-running work tied to the state's lifetime. Changing context always goes through assign, never a mutation, so every change is visible in the definition. Because the actions are named in the machine and implemented in setup() (or supplied later with machine.provide()), a test can swap the real network call for a fake without touching the logic.

Mistakes worth avoiding

  • States named after events. A state is a mode you can be in — saving, not onSaveClicked. If the name is a verb in the past tense, it is probably an event.
  • Data smuggled into states. loadedUser1, loadedUser2 is context wearing a state's clothes. Ten states that differ by one value are one state and one field.
  • Duplicating what React already tracks. Text inside an input, a scroll position and anything derivable from props do not belong in a machine.
  • One giant machine. Split by responsibility — a checkout flow, a payment form, an address form — and let a parent machine start the others as actors and hear back from them.
  • Reaching for it first. Draw the states on paper. If there are two of them, write the useState.

Using it across components

const CheckoutContext = createActorContext(checkoutMachine);

<CheckoutContext.Provider><Steps /></CheckoutContext.Provider>

const step = CheckoutContext.useSelector((s) => s.value);
const actor = CheckoutContext.useActorRef();       // stable; does not re-render on change

useMachine creates the machine for one component's lifetime. When a wizard, a player or an upload queue must be shared, createActorContext provides the running actor and useSelector subscribes to just a slice — the same selector idea as in state libraries, and for the same reason. To restore a saved session, pass a persisted snapshot option when creating the actor.

Where it earns its place, and where it does not

A machine fitsA plain useState is enough
Multi-step wizards and checkout flowsAn open/closed toggle
Upload, payment and auth flows with retries and cancelA controlled input
Media players, drag interactions, editors with modesData derived from props
Anything where "which events are valid now?" is a real questionTwo states with one transition

The test is the question in the last row. If you find yourself writing if (status === "x") return at the top of handlers to ignore events that arrived at the wrong time, the states and transitions already exist — they are just unwritten. That is the moment to write them down. For everything smaller, the reducer or a union type gives most of the benefit with no dependency.

Testing gets easier

test("cannot retry from idle", () => {
  const actor = createActor(userMachine).start();
  actor.send({ type: "RETRY" });
  expect(actor.getSnapshot().value).toBe("idle");
});

A machine is a pure description, so you can test the logic without rendering: send events, assert on the state. And because the definition is data, tools can draw it as a diagram — Stately's editor does, and the picture is the quickest way to show a product manager the case nobody specified.

Interview answer, one sentence

"A state machine replaces a pile of booleans with one state from a fixed list and explicit transitions, so impossible combinations cannot be reached and an event in the wrong state is ignored by construction; I would use a reducer or a union type for simple cases and XState when there are timeouts, guards, nesting or several actors involved."

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsRun a state machineadvanced
  • 6 testsOnly the transitions you name are possibleadvanced
All 54 for this level →
A21

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. A20Previous · A20Reviewing React, and knowing when not to use it
  2. A21you are here
  3. Next · A22Feature flagsDeploying code and releasing it are different decisions, and a flag is what separates them.5 min read

01On this page

9 sections · 0%

  1. 01The bug a machine prevents
  2. 02Start without a library
  3. 03XState v5
  4. 04The parts a reducer does not give you
  5. 05Where side effects go
  6. 06Mistakes worth avoiding
  7. 07Using it across components
  8. 08Where it earns its place, and where it does not
  9. 09Testing gets easier

[] chapters · t top