Building a Simple State Machine in TypeScript
If you've ever written a component with isLoading, isError, hasData, and isEmpty as separate booleans, you've probably also shipped a bug where two of them were true at the same time and the UI showed a spinner and an error message stacked on top of each other. A state machine fixes this at the type level: instead of independent flags that can combine into nonsense, you have exactly one named state at a time, with clearly defined transitions between them.
The boolean-soup problem
Here's the kind of component state that state machines replace:
interface FetchState {
isLoading: boolean;
isError: boolean;
data: string[] | null;
error: string | null;
}
Four fields, sixteen possible combinations, and only four of them are actually valid (loading, success, error, idle). Nothing stops isLoading: true and isError: true from existing at once — you just have to remember not to do that, everywhere, forever.
Modeling states as a union
A state machine represents "what's happening right now" as a single tagged union instead of a bag of flags:
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };
Now data only exists when status is "success", and message only exists when status is "error". TypeScript's control-flow narrowing enforces this for you:
function render(state: FetchState): string {
switch (state.status) {
case "idle":
return "Nothing yet.";
case "loading":
return "Loading…";
case "success":
return `Got ${state.data.length} items.`; // data is known to exist here
case "error":
return `Failed: ${state.message}`; // message is known to exist here
}
}
Try to access state.data inside the "error" branch and TypeScript will reject it at compile time — not at 2am when a user reports a blank screen.
Adding transitions
A state on its own is just a snapshot. The other half of a state machine is the set of allowed transitions — which states can move to which other states, and under what event. Model the events the same way you modeled the states:
type FetchEvent =
| { type: "FETCH" }
| { type: "RESOLVE"; data: string[] }
| { type: "REJECT"; message: string }
| { type: "RESET" };
function transition(state: FetchState, event: FetchEvent): FetchState {
switch (state.status) {
case "idle":
if (event.type === "FETCH") return { status: "loading" };
return state;
case "loading":
if (event.type === "RESOLVE") return { status: "success", data: event.data };
if (event.type === "REJECT") return { status: "error", message: event.message };
return state;
case "success":
case "error":
if (event.type === "RESET") return { status: "idle" };
return state;
}
}
Every branch of this function is explicit about what's allowed. You can't accidentally jump from "idle" straight to "success" — there's no code path that does it. If a future requirement needs that transition, it has to be added deliberately, in one place, rather than emerging as a side effect of some component setting three booleans in the wrong order.
Wiring it into a component
The transition function is pure — no side effects, easy to unit test in isolation. Wrapping it around actual async work is a thin layer on top:
class FetchController {
private state: FetchState = { status: "idle" };
private listeners: Array<(s: FetchState) => void> = [];
private setState(next: FetchState) {
this.state = next;
this.listeners.forEach((listener) => listener(next));
}
subscribe(listener: (s: FetchState) => void) {
this.listeners.push(listener);
listener(this.state);
}
async load(url: string) {
this.setState(transition(this.state, { type: "FETCH" }));
try {
const res = await fetch(url);
const data: string[] = await res.json();
this.setState(transition(this.state, { type: "RESOLVE", data }));
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
this.setState(transition(this.state, { type: "REJECT", message }));
}
}
}
Every state change goes through transition, so the "only valid combinations are reachable" guarantee holds for the whole lifetime of the controller, not just at the type level for one render.
Testing transitions instead of components
Because transition is a plain function with no dependencies, you can test every path without mounting anything:
test("FETCH moves idle to loading", () => {
const result = transition({ status: "idle" }, { type: "FETCH" });
expect(result).toEqual({ status: "loading" });
});
test("REJECT during loading carries the error message", () => {
const result = transition(
{ status: "loading" },
{ type: "REJECT", message: "network down" },
);
expect(result).toEqual({ status: "error", message: "network down" });
});
test("FETCH while already loading is a no-op", () => {
const state: FetchState = { status: "loading" };
const result = transition(state, { type: "FETCH" });
expect(result).toBe(state);
});
That last test matters more than it looks: it documents, and enforces, that a duplicate FETCH event while already loading doesn't restart anything or double-fire a request.
When this is overkill
A two-state toggle (open / closed) doesn't need this machinery — a single boolean is fine and a union type would just be ceremony. Reach for an explicit state machine when a component has three or more mutually exclusive states, when the transitions between them are the actual source of bugs (loading states are the classic case), or when you keep finding yourself writing if (isLoading && !isError && data) guards that only exist to compensate for state that could be invalid but shouldn't be.
Takeaway
Booleans multiply — four flags create sixteen states, most of them nonsense. A tagged union collapses that down to exactly the states that are actually valid, and a single transition function becomes the one place that decides what can happen next. The compiler enforces the rest.