State Machines
Date: 2026-08-17
Modelling a process as a fixed set of states and the legal transitions between them. It removes a whole class of bug by making impossible states impossible to represent, rather than merely unlikely.
A state machine defines every state a thing can be in, and every permitted move between them. Anything not listed cannot happen.
The problem it removes
The usual way state gets modelled — a handful of independent booleans:
isLoading isError hasData
false false false ← initial
true false false ← loading
false false true ← loaded
false true false ← failed
true true true ← ???
true true false ← ???
Three booleans is eight combinations, of which four are meaningless. Nothing prevents them, so every render has to defend against states that should not exist — and the bug where a spinner shows over an error message is exactly one of those combinations occurring.
The state machine version:
state: 'idle' | 'loading' | 'ready' | 'error'
Four states, all meaningful, illegal ones unrepresentable. The defensive checks disappear because the situation can’t arise — Type Systems.
Transitions are the other half
Listing states is only useful if you also constrain the moves:
STATE EVENT → NEXT STATE
idle FETCH → loading
loading RESOLVE → ready
loading REJECT → error
loading CANCEL → idle
ready FETCH → loading
error RETRY → loading
anything else: ignored
“Anything else: ignored” is where the value is. A RESOLVE arriving while in idle — a response from a request that was cancelled — is discarded rather than writing stale data into the UI. That’s a race condition eliminated by construction — Race Conditions.
A worked example
An order, which everyone models and most model badly:
┌──────────┐
│ pending │
└────┬─────┘
pay ────┤──── cancel
▼ ▼
┌────────┐ ┌───────────┐
│ paid │ │ cancelled │
└───┬────┘ └───────────┘
fulfil ─┤─ refund
▼ ▼
┌───────────┐ ┌──────────┐
│ fulfilled │ │ refunded │
└─────┬─────┘ └──────────┘
return ┤
▼
┌──────────┐
│ returned │
└──────────┘
What the diagram makes obvious: you cannot fulfil a cancelled order, you cannot cancel a fulfilled one, and refunded is reachable from paid but not from pending. Those rules exist in every ecommerce system; the question is whether they’re written down or scattered across twenty if statements — Revenue Metrics.
Where they earn their place
- Async data fetching — the four-state example above, in every component that loads anything
- Checkout and multi-step forms — steps with rules about which are reachable
- Order, subscription and payment lifecycles
- Media players, uploads, wizards — anything with a play/pause/buffer/ended character
- Feature rollout stages — off, internal, percentage, on — Feature Flags
- Parsers and tokenisers, where the theory came from
Where they’re overkill
Two states with one transition is a boolean. Reaching for a state machine there adds ceremony and removes nothing, and the vault’s compression rules apply to code as much as to notes.
The threshold is roughly: three or more states, or any transition that must be forbidden. Below that, a boolean is honest.
Practical notes
- Model the state, derive the display.
isLoadingbecomesstate === 'loading'— computed, never stored, so it can’t disagree - Attach data to the state, not beside it. A
readystate carries the data; anerrorstate carries the error. Neither carries the other, which is what stops stale data surviving a failed refetch - Name states after what is true, not what is happening —
readyrather thanfinishedLoading - Draw it before coding it. Most of the value arrives during the drawing, when someone asks whether a transition that nobody had considered should exist
- The transition table is testable, exhaustively, in a way scattered conditionals aren’t