State Machine Pattern and XState
The Boolean Soup Problem
You're building a data fetcher. It has states: idle, loading, success, error. The typical approach:
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
Five state variables. How many combinations? 2^5 = 32. How many are valid? Four (idle, loading, success, error). That means 28 of 32 possible states are impossible — like isLoading: true, isSuccess: true, isError: true simultaneously. But nothing in the code prevents reaching them. A missed setIsLoading(false) and your UI shows both a spinner and the data.
State machines eliminate impossible states by design. Instead of independent booleans, you have one state variable that can only be in one state at a time, and explicit transitions that define how to move between states.
Think of a state machine like a board game. Your piece (the current state) sits on one square at a time. You can only move to squares connected by arrows (transitions). There's no way to jump to a disconnected square, and you can't be on two squares at once. The board itself prevents impossible moves. Compare this to boolean soup, which is like a chess board where every piece can teleport anywhere — chaos is one bad move away.
A Minimal State Machine
Here's the simplest state machine that's actually useful:
type State = "idle" | "loading" | "success" | "error";
type Event = "FETCH" | "RESOLVE" | "REJECT" | "RESET";
interface MachineConfig {
initial: State;
states: Record<State, {
on?: Partial<Record<Event, State>>;
}>;
}
function createMachine(config: MachineConfig) {
let current: State = config.initial;
return {
get state() { return current; },
send(event: Event): State {
const stateConfig = config.states[current];
const nextState = stateConfig.on?.[event];
if (!nextState) {
console.warn(`No transition for "${event}" in state "${current}"`);
return current;
}
current = nextState;
return current;
},
};
}
const fetchMachine = createMachine({
initial: "idle",
states: {
idle: { on: { FETCH: "loading" } },
loading: { on: { RESOLVE: "success", REJECT: "error" } },
success: { on: { FETCH: "loading", RESET: "idle" } },
error: { on: { FETCH: "loading", RESET: "idle" } },
},
});
fetchMachine.send("FETCH"); // "loading"
fetchMachine.send("RESOLVE"); // "success"
fetchMachine.send("REJECT"); // still "success" — no transition defined!
fetchMachine.send("RESET"); // "idle"
The machine in the success state ignores the REJECT event because there's no transition for it. You cannot reach an impossible state. The state machine's structure makes invalid transitions literally unrepresentable.
State Machine with Context
Real state machines carry data alongside their state. This data is called "context" (or "extended state"):
interface FetchContext<T> {
data: T | null;
error: string | null;
retries: number;
}
type FetchState = "idle" | "loading" | "success" | "error" | "retrying";
type FetchEvent<T> =
| { type: "FETCH" }
| { type: "RESOLVE"; data: T }
| { type: "REJECT"; error: string }
| { type: "RETRY" }
| { type: "RESET" };
function createFetchMachine<T>() {
let state: FetchState = "idle";
let context: FetchContext<T> = { data: null, error: null, retries: 0 };
const transitions: Record<FetchState, Partial<Record<string, (event: FetchEvent<T>) => FetchState>>> = {
idle: {
FETCH: () => {
context.retries = 0;
return "loading";
},
},
loading: {
RESOLVE: (event) => {
if (event.type === "RESOLVE") {
context.data = event.data;
context.error = null;
}
return "success";
},
REJECT: (event) => {
if (event.type === "REJECT") {
context.error = event.error;
}
return context.retries < 3 ? "retrying" : "error";
},
},
retrying: {
FETCH: () => {
context.retries++;
return "loading";
},
},
success: {
FETCH: () => "loading",
RESET: () => {
context = { data: null, error: null, retries: 0 };
return "idle";
},
},
error: {
FETCH: () => {
context.retries = 0;
return "loading";
},
RESET: () => {
context = { data: null, error: null, retries: 0 };
return "idle";
},
},
};
return {
get state() { return state; },
get context() { return { ...context }; },
send(event: FetchEvent<T>) {
const handler = transitions[state][event.type];
if (handler) {
state = handler(event);
}
return { state, context: { ...context } };
},
};
}
Production Scenario: Auth Flow
Authentication is a perfect state machine use case. There are clear states with defined transitions:
type AuthState = "loggedOut" | "authenticating" | "authenticated" | "refreshing" | "error";
type AuthEvent =
| { type: "LOGIN"; email: string; password: string }
| { type: "LOGIN_SUCCESS"; token: string; user: { id: string; name: string } }
| { type: "LOGIN_FAILURE"; error: string }
| { type: "LOGOUT" }
| { type: "TOKEN_EXPIRED" }
| { type: "REFRESH_SUCCESS"; token: string }
| { type: "REFRESH_FAILURE" };
interface AuthContext {
user: { id: string; name: string } | null;
token: string | null;
error: string | null;
}
function createAuthMachine() {
let state: AuthState = "loggedOut";
let context: AuthContext = { user: null, token: null, error: null };
function transition(event: AuthEvent): AuthState {
switch (state) {
case "loggedOut":
if (event.type === "LOGIN") return "authenticating";
break;
case "authenticating":
if (event.type === "LOGIN_SUCCESS") {
context.user = event.user;
context.token = event.token;
context.error = null;
return "authenticated";
}
if (event.type === "LOGIN_FAILURE") {
context.error = event.error;
return "error";
}
break;
case "authenticated":
if (event.type === "LOGOUT") {
context = { user: null, token: null, error: null };
return "loggedOut";
}
if (event.type === "TOKEN_EXPIRED") return "refreshing";
break;
case "refreshing":
if (event.type === "REFRESH_SUCCESS") {
context.token = event.token;
return "authenticated";
}
if (event.type === "REFRESH_FAILURE") {
context = { user: null, token: null, error: null };
return "loggedOut";
}
break;
case "error":
if (event.type === "LOGIN") return "authenticating";
break;
}
return state;
}
return {
get state() { return state; },
get context() { return { ...context }; },
send(event: AuthEvent) {
state = transition(event);
return { state, context: { ...context } };
},
};
}
The machine makes impossible states unrepresentable:
- You cannot refresh a token while logged out (no TOKEN_EXPIRED transition from loggedOut)
- You cannot log in while already authenticated (no LOGIN transition from authenticated)
- You cannot have a user with no token or a token with no user (LOGIN_SUCCESS sets both atomically)
Using State Machines with React
import { useReducer, useCallback } from "react";
type MachineState = "idle" | "loading" | "success" | "error";
type MachineEvent = { type: "FETCH" } | { type: "RESOLVE"; data: unknown } | { type: "REJECT"; error: string } | { type: "RESET" };
interface MachineSnapshot {
state: MachineState;
data: unknown;
error: string | null;
}
function machineReducer(snapshot: MachineSnapshot, event: MachineEvent): MachineSnapshot {
switch (snapshot.state) {
case "idle":
if (event.type === "FETCH") return { state: "loading", data: null, error: null };
return snapshot;
case "loading":
if (event.type === "RESOLVE") return { state: "success", data: event.data, error: null };
if (event.type === "REJECT") return { state: "error", data: null, error: event.error };
return snapshot;
case "success":
if (event.type === "FETCH") return { state: "loading", data: null, error: null };
if (event.type === "RESET") return { state: "idle", data: null, error: null };
return snapshot;
case "error":
if (event.type === "FETCH") return { state: "loading", data: null, error: null };
if (event.type === "RESET") return { state: "idle", data: null, error: null };
return snapshot;
}
}
function useFetchMachine() {
const [snapshot, send] = useReducer(machineReducer, {
state: "idle",
data: null,
error: null,
});
const fetchData = useCallback(async (url: string) => {
send({ type: "FETCH" });
try {
const res = await fetch(url);
const data = await res.json();
send({ type: "RESOLVE", data });
} catch (e) {
send({ type: "REJECT", error: (e as Error).message });
}
}, []);
return { ...snapshot, fetchData, send };
}
The state machine reducer replaces a tangle of useState calls with a single, predictable state object. The UI simply matches on the state:
function UserList() {
const { state, data, error, fetchData } = useFetchMachine();
return (
<div>
{state === "idle" && <button onClick={() => fetchData("/api/users")}>Load Users</button>}
{state === "loading" && <div>Loading...</div>}
{state === "success" && <ul>{(data as string[]).map(u => <li key={u}>{u}</li>)}</ul>}
{state === "error" && <div>Error: {error} <button onClick={() => fetchData("/api/users")}>Retry</button></div>}
</div>
);
}
When to Use State Machines vs. Simple State
State machines add structure. That structure has a cost — more code, more abstractions. Use them when the cost is justified:
Use state machines when:
- States have defined transitions (not arbitrary)
- You need to prevent impossible states
- The flow has 4+ states with complex transitions
- Multiple actors can trigger transitions
- You need to visualize or document the flow
Use simple state when:
- Two or three states with obvious transitions
- Toggle behavior (open/closed, visible/hidden)
- The state is just data, not a workflow
| Pattern | Example | States |
|---|---|---|
| Simple boolean | Modal open/closed | 2 |
| Discriminated union | `"idle" | "active"` |
| State machine | Auth flow, checkout wizard | 4+ |
| What developers do | What they should do |
|---|---|
| Modeling every piece of UI state as a state machine A state machine for a boolean toggle is over-engineering. The value of state machines is preventing impossible state combinations — if there are only two states and one transition, a boolean is perfectly fine | Use state machines for complex workflows with defined transitions. Use simple state for toggles and counters |
| Using separate boolean flags instead of a discriminated union or state machine Five independent booleans create 32 possible combinations. A union type of 5 states creates exactly 5. The type system enforces that only valid states exist, and pattern matching ensures you handle all of them | Use a single state variable with union types to represent mutually exclusive states |
| Adding transitions between every pair of states just in case The power of state machines is in what they PREVENT. If you add a LOGOUT transition from the authenticating state, users can now cancel mid-login, which might leave your auth service in a broken state. Missing transitions are a feature, not a bug | Only define transitions that are actually valid in your domain |
Challenge
Build a state machine for a multi-step form wizard with validation, where each step must be validated before proceeding to the next.
Try to solve it before peeking at the answer.
// Requirements:
// 1. States: personal-info, address, payment, review, submitted
// 2. NEXT event: moves forward only if current step is valid
// 3. BACK event: moves backward (always allowed)
// 4. SUBMIT event: only from review state
// 5. Each step has a validate function
// 6. Context stores form data for each step
// const wizard = createFormWizard({
// steps: ["personal-info", "address", "payment", "review"],
// validators: {
// "personal-info": (data) => data.name?.length > 0,
// "address": (data) => data.zip?.length === 5,
// "payment": (data) => data.card?.length === 16,
// },
// });
// wizard.send({ type: "SET_DATA", data: { name: "Alice" } });
// wizard.send({ type: "NEXT" }); // moves to address
// wizard.send({ type: "NEXT" }); // stays on address (invalid)- 1State machines replace boolean soup with a single state variable and explicit transitions — impossible states become unrepresentable
- 2Use useReducer in React to implement state machine logic — it centralizes all transitions in one function
- 3Missing transitions are a feature, not a bug — they define what is NOT allowed in each state
- 4Use state machines for workflows with 4+ states and complex transitions. Use simple state for toggles and counters
- 5Separate state (what mode are we in) from context (what data do we have) — state drives transitions, context carries data