State Machines
The bug report had a screenshot attached. A spinner, still spinning, sitting directly on top of a red “Something went wrong” banner, and behind both of them a table showing last Tuesday’s rows. Three states at once, in one component, each insisting it was the truth.
Here is the state that produced it. If you have written a data-loading component you have written this, or something within a rename of it:
let state = {
isLoading: false,
isError: false,
isSuccess: false,
data: null,
};
async function load() {
state.isLoading = true;
try {
state.data = await fetchUsers();
state.isSuccess = true;
} catch (err) {
state.isError = true;
}
state.isLoading = false;
}
Read it and it looks fine. It is not fine. Call load() a second time after a failure and isError is still true from the last run, because nothing ever set it back. Now isLoading and isError are both true, the render sees both, and you get a spinner welded to an error banner. The stale data is still hanging around too, so the old table renders under it. Not one of those three booleans is lying. They just disagree, and the code has no opinion about which combinations are allowed.
That is the whole disease. Four booleans do not describe four situations. They describe sixteen.
Sixteen states, four of them real
Four independent flags give you two-to-the-fourth combinations. Most of them are nonsense that your code will happily construct and render anyway.
Only four of those rows are states you meant. The rest are combinations the type allows and your logic forgot to forbid. Loading and error together. Error and success together. Success with no data to show. They are not unlikely, they are one missed reset away, and the compiler is fine with every one of them.
A finite state machine deletes this entire category of bug by refusing to let the impossible rows exist in the first place.
Collapse the flags into one field
The fix is almost insultingly small. Replace the four booleans with one field that holds a single value at a time.
let state = { status: "idle" };
status is one of "idle", "loading", "success", or "error". It cannot be two of them. There is no way to be loading and error at once, because there is nowhere to write the second value.
The data rides along with the state that owns it, never on its own:
async function load() {
state = { status: "loading" };
try {
const data = await fetchUsers();
state = { status: "success", data };
} catch (err) {
state = { status: "error", message: err.message };
}
}
Notice what changed in style, not just in shape. Nobody flips one field anymore. Every step assigns a whole new object, so you can never half-update your way into a contradiction. data exists only in the success object. message exists only in the error object. Ask for state.data while you are in error and there is nothing there, by construction.
This already kills the opening bug. But a status field on its own only says where you are. It says nothing about how you are allowed to move. That is the other half.
Draw the transitions before you code them
A finite state machine is two lists. The states a thing can be in, and the moves that are legal between them. That is it. Everything else is bookkeeping.
The moment you write those two lists down, an interesting thing happens: the illegal moves are the ones you simply do not draw. There is no arrow from success back to loading except by starting a fresh fetch. There is no arrow from idle straight to success, because you cannot have data you never asked for. Illegal transitions are not guarded against. They just do not exist.
Modelling on paper first is not ceremony. It is the cheapest place to notice that you never handled error, or that two people disagree about whether success can refetch. Drawing catches the missing arrow in thirty seconds. Debugging the missing arrow in production catches you at 3am.
The machine is a table and a send()
That diagram translates straight into a data structure. The states are keys, and each state maps its legal events to the state they lead to.
const fetchMachine = {
initial: "idle",
states: {
idle: { FETCH: "loading" },
loading: { RESOLVE: "success", REJECT: "error" },
success: { FETCH: "loading" },
error: { FETCH: "loading" },
},
};
You can read the whole behaviour off the table. From loading, the only two events that mean anything are RESOLVE and REJECT. Send anything else and there is no entry to follow, so nothing happens. That “nothing happens” is the feature.
The engine that runs the table is about ten lines. It holds the current state, and its only real job is to refuse illegal moves.
function createMachine(config) {
let state = config.initial;
return {
get state() {
return state;
},
can(event) {
return Boolean(config.states[state]?.[event]);
},
send(event) {
const next = config.states[state]?.[event];
if (!next) return false; // illegal transition: ignored, state unchanged
state = next;
return true;
},
};
}
Drive it and the illegal moves bounce off:
const m = createMachine(fetchMachine);
m.send("FETCH"); // idle → loading → true
m.send("FETCH"); // no FETCH here → false, still "loading"
m.send("RESOLVE"); // loading → success → true
m.send("RESOLVE"); // no RESOLVE here → false, still "success"
The second FETCH and the second RESOLVE are the bug from the opening, arriving as events instead of stray assignments, and the machine drops them on the floor. A double-click on your submit button, a resolve landing after you already navigated away, a websocket message for a request you cancelled: all of them become an event sent to a state that does not accept it, and all of them do nothing instead of corrupting your state.
Play with it. The four buttons fire events at the machine. Watch the current state, watch which events are allowed from here, and try an illegal one. It visibly refuses.
Send RESOLVE while you are still in idle and nothing moves, because a resolve with no request in flight is meaningless. That single rule, enforced in one place, is worth more than any amount of defensive if (isLoading && !isError) scattered through your render code.
This is the “State” pattern
What you just built is the Gang of Four State pattern, minus the costume. The classic definition is “an object whose behaviour changes when its internal state changes, so it appears to change its class.” The textbook implementation is a State interface, one class per concrete state, each implementing the same methods differently, and a context object that forwards calls to whichever state object is currently active.
In a language where behaviour has to travel inside an object, that ceremony buys you something. In JavaScript it usually does not, because behaviour is already a value you can store in a table. The transition table is the pattern. Each state is a key; the “different behaviour per state” is just a different row.
If a state needs to do different things, not only transition differently, hang the behaviour off the same table:
const doorMachine = {
initial: "locked",
states: {
locked: {
on: { UNLOCK: "closed" },
render: () => "🔒 turn the key",
},
closed: {
on: { OPEN: "open", LOCK: "locked" },
render: () => "🚪 push to open",
},
open: {
on: { CLOSE: "closed" },
render: () => "🌂 mind the gap",
},
},
};
Now doorMachine.states[current].render() gives you the behaviour for the current state without a single if. The object appears to change its class as it moves, exactly as the pattern promises, and it is a lookup.
That contrast is the whole relationship between the two patterns. Strategy is a slot you fill once and leave alone. State is a slot the machine keeps refilling, and the current occupant decides who moves in next. Same mechanism, a behaviour behind a common shape, opposite intent. You will notice there is no inheritance anywhere here, which is the usual story in JavaScript: composition over inheritance wins again, because a table of functions beats a hierarchy of state classes for every metric that matters.
Guards, actions, and context
Two states and a clean set of transitions cover a surprising amount of real code. Then reality asks for three more things, and a good machine has room for all of them.
Context is the extra data a machine carries that is not itself a state. An order machine is pending or paid, but it also needs to remember the order id and the amount. Those live in context, alongside the state, updated as you move.
Actions are side effects that fire on a transition. Charging a card, sending an email, starting a timer. The transition says when; the action says what.
Guards are conditions that must hold for a transition to be allowed at all. A CHECKOUT event should move you to payment only if the cart is not empty. A guard is how you say “this move is legal, but only sometimes.”
Fold all three into the table and send grows by three lines:
function createMachine(config, context = {}) {
let state = config.initial;
return {
get state() { return state; },
get context() { return context; },
send(event, payload) {
const rule = config.states[state]?.on?.[event];
if (!rule) return false; // no such transition
if (rule.guard && !rule.guard(context, payload)) return false; // blocked by guard
state = rule.target;
if (rule.action) context = rule.action(context, payload); // run the effect
return true;
},
};
}
A checkout flow reads almost like the spec you were handed:
const checkout = {
initial: "cart",
states: {
cart: {
on: {
CHECKOUT: { target: "payment", guard: (ctx) => ctx.items > 0 },
},
},
payment: {
on: {
PAY: { target: "placed", action: (ctx, p) => ({ ...ctx, orderId: p.id }) },
CANCEL: { target: "cart" },
},
},
placed: {},
},
};
An empty cart cannot reach payment, because the guard says no. A PAY records the order id into context as it transitions. And placed has no outgoing events, which makes it a final state: once an order is placed, this machine is done, and no stray event can un-place it.
Statecharts, for when it grows
A flat table starts to hurt when your states multiply along more than one axis. A media player is playing or paused. It also has captions on or off. Model that as a flat enum and you get playingCaptionsOn, playingCaptionsOff, pausedCaptionsOn, pausedCaptionsOff, and every event has to be written four times. Add a third axis and you are drowning. This is called state explosion, and it is the flat machine’s one genuine weakness.
Statecharts, invented by David Harel in the 1980s, fix it with two ideas that a plain FSM lacks:
- Nested (hierarchical) states. A state can contain a whole machine.
activeis a state, and inside it lives theplaying/pausedsub-machine. Events handled by the parent do not need repeating in every child. - Parallel (orthogonal) regions. A state can run several sub-machines at once, side by side. Playback and captions are two regions of
active, live simultaneously and independent, soplayingandcaptions onare two small facts rather than one combined state.
Add the guards and entry/exit actions from the last section and you have the full statechart vocabulary.
Hand-rolling nested and parallel states gets fiddly fast, and this is the point where a library earns its keep. XState is the standard one in JavaScript, and version 5 gives you all of the above with a declarative config:
import { createMachine, createActor } from "xstate";
const player = createMachine({
id: "player",
initial: "idle",
states: {
idle: { on: { LOAD: "active" } },
active: {
type: "parallel",
states: {
playback: {
initial: "paused",
states: {
paused: { on: { PLAY: "playing" } },
playing: { on: { PAUSE: "paused" } },
},
},
captions: {
initial: "off",
states: {
off: { on: { TOGGLE_CC: "on" } },
on: { on: { TOGGLE_CC: "off" } },
},
},
},
},
},
});
const actor = createActor(player);
actor.subscribe((snapshot) => render(snapshot.value));
actor.start();
actor.send({ type: "LOAD" });
actor.send({ type: "PLAY" });
You define the machine, createActor gives you a running instance, you subscribe to its snapshots, and events go in through send. It is the same table-and-send idea you built by hand, plus nesting, parallelism, guards, entry and exit actions, delayed events, and spawned child machines. When your hand-written table sprouts a second axis or a set of sub-flags, stop hand-rolling and reach for this. Before that, it is weight you do not need.
Where explicit state pays off
Once you have the lens, you see machines everywhere, and the honest ones announce themselves by having illegal transitions worth forbidding:
- Async request status. The running example. Every fetch, mutation, and upload is
idle → loading → success | error, and every one of them has a stale-response bug waiting if you let the flags disagree. - Form and wizard flows. Multi-step signup, a checkout, an onboarding tour. Step 3 should not be reachable without a valid step 2, and a machine says so structurally.
- Media and editors. Players, recorders, drag interactions, a canvas tool that is
idle → drawing → committed. Anything with modes is a machine wearing a UI. - Connection and process lifecycles. A socket is
connecting → open → closing → closed. A server draining for graceful shutdown movesserving → draining → closedand must refuse new work indraining; that refusal is a missing transition, not a scattered flag. - Order and payment status.
pending → paid → shipped → delivered, withrefundedbranching off. The events often arrive as webhooks from a payment provider, landing out of order, sometimes twice, and a machine that ignores apaidevent on an already-shipped order is doing exactly its job. Theerror → loadingretry edge is where retries and backoff plug in. - Game entities. The oldest use. An enemy is
idle → patrol → chase → attack → dead, anddeadis final, so nothing resurrects it by accident.
The common thread: the thing has genuinely distinct modes, and moving between them the wrong way is a bug you want to make impossible rather than merely unlikely.
When a state machine is the wrong tool
A pattern lesson that only sells the pattern is an advertisement. So here is when to walk past this one.
A real boolean is a real boolean. If a thing has two states that will never become three, and no illegal combination is possible, it is a toggle. isOpen on a dropdown does not need a machine. Wrapping every flag in a states table is the same over-abstraction as building a plugin system for a program with no plugins.
One fetch does not need XState. The library is superb and also a dependency, a mental model, and a bundle cost. For a single spinner, the discriminated union from earlier, or the ten-line table, is the right size. Reach for the library when you have nesting, parallelism, or a diagram a stakeholder wants to see, not before. Importing a statechart engine to render a loading state is the tell of someone who just learned the pattern.
Do not fake it. The worst of both worlds is a “machine” whose send still pokes individual booleans underneath, or a states table where the real logic lives in a pile of if statements the table only pretends to gate. If illegal states are still representable, you have the ceremony without the guarantee. The entire value is that the bad combinations cannot be written down. Keep that or keep the booleans, but do not pay for a machine that still lets the impossible happen.
Watch for the explosion. If your flat state count is multiplying, that is not a reason to give up on machines, it is the signal to go hierarchical or parallel, or to split one overloaded machine into two smaller ones that each own a single concern. Two clear machines beat one with forty states.
Summary
- Independent booleans like
isLoading,isError,isSuccess, andhasDatadescribe far more combinations than you meant. Four flags is sixteen states, and the twelve nonsense ones are bugs the shape permits. - Replace the flags with one state field that holds a single value, and let the data ride on the state that owns it. In TypeScript that is a discriminated union, and the compiler makes the impossible rows unrepresentable.
- A finite state machine is two lists: the legal states, and the legal moves between them. Illegal transitions are not guarded, they are simply not in the table, so they cannot happen.
- The engine is a table plus a
send(event)that looks up the current row and only moves on a match. Stray events, double-clicks, and late responses become no-ops instead of corruption. - This is the GoF State pattern, which in JavaScript collapses to a transition table rather than a class per state. It is Strategy that swaps itself: State keeps refilling the behaviour slot, and the current behaviour picks its own successor.
- Grow the table with context (data the machine carries), actions (effects on a transition), and guards (conditions that gate a move). One
sendis the only place state ever changes, which hands you an audit log and undo for free. - Statecharts add nested and parallel states to beat state explosion. Hand-roll until it hurts, then reach for a library like XState v5 (
createMachine,createActor,send,subscribe) for the nesting, parallelism, and guards. - Use it when a thing has distinct modes and moving between them wrongly is a bug you want made impossible: request status, wizards, players, connections, order and payment lifecycles, game entities. Avoid it when two states will never grow (that is a boolean), when a single fetch would only gain a dependency, or when the “machine” still lets illegal states exist underneath.