Skip to content

Understanding State Machines in XMachines

XMachines uses XState v5 as its state machine engine. This page explains what finite state machines are, how XMachines extends them with routing and view metadata, and why this design eliminates an entire category of bugs common in traditional frontend architecture.

After reading this, you will understand what a machine definition actually encodes — and why state machines are a better unit of business logic than component-level state or ad-hoc if/else trees.


What a finite state machine is

A finite state machine (FSM) is a model of computation with three properties:

  1. It is always in exactly one state from a finite set of possible states.
  2. It transitions between states only in response to events.
  3. The same event in different states can produce different outcomes (or no transition at all).

These three properties, taken together, make behavior deterministic and exhaustive. You cannot end up in an unlisted state. You cannot receive an event that produces an unspecified outcome — unhandled events are simply ignored.

In traditional component-level state (e.g., boolean flags, useState combinations), you typically end up managing n booleans for n conditions. With n booleans, you have 2^n possible combinations — and only a handful of them are actually valid. State machines force you to enumerate only the valid states.

Example: A login flow with two booleans (isLoading, isError) has four combinations: {false,false}, {true,false}, {false,true}, {true,true}. The last one — loading and error simultaneously — is impossible in practice, but the code has no way to rule it out. A state machine with states idle | loading | success | error makes the impossible unrepresentable.


How XMachines uses XState v5

XMachines wraps XState v5 via @xmachines/play-xstate. You define machines using XState’s setup().createMachine() API:

import { setup } from "xstate";
const authSetup = setup({
types: {
context: {} as { username: string | null },
events: {} as { type: "auth.login"; username: string } | { type: "auth.logout" },
input: {} as undefined,
},
});
const authMachine = authSetup.createMachine({
id: "auth",
initial: "unauthenticated",
context: { username: null },
states: {
unauthenticated: {
on: {
"auth.login": {
target: "authenticated",
actions: authSetup.assign({
username: ({ event }) => event.username,
}),
},
},
},
authenticated: {
on: {
"auth.logout": {
target: "unauthenticated",
actions: authSetup.assign({ username: null }),
},
},
},
},
});

Key patterns:

  • Always use setup({ types }) before createMachine. The type declarations let TypeScript check that your events, context fields, and actions are consistent throughout the machine.
  • Use setup.assign(), not the bare assign from xstate. This keeps the type checker aware of which context fields the action touches.
  • Event names use lowercase dot-separated namespaces: "auth.login", "play.route", "form.submit". This convention makes the event log readable and avoids collisions.

State node metadata: routing and views

XMachines extends XState’s meta field on each state node. This is where routing intent and view structure live:

import { typedSpec } from "@xmachines/play-actor";
const appMachine = setup({
/* ... */
}).createMachine({
id: "app",
initial: "home",
states: {
home: {
meta: {
route: "/",
view: typedSpec({
root: "root",
elements: {
root: { type: "HomePage", props: { title: "Welcome" }, children: [] },
},
}),
},
},
login: {
meta: {
route: "/login",
view: typedSpec({
root: "root",
elements: {
root: { type: "LoginPage", props: { title: "Sign in" }, children: [] },
},
}),
},
},
dashboard: {
meta: {
route: "/dashboard",
view: typedSpec({
root: "root",
elements: {
root: { type: "DashboardPage", props: { title: "Overview" }, children: [] },
},
}),
},
},
},
});

meta.route is a string path. When the machine enters a state, actor.currentRoute (a Signal.Computed) derives this path and emits it. The router bridge reads it and updates the URL.

meta.view is a PlaySpec — a @json-render/core spec object describing what to render. Use typedSpec<TContext>(...) from @xmachines/play-actor to validate contextProps entries at compile time. When the machine enters a state, actor.currentView is updated with this spec. The renderer reads it and projects it through framework components.

The machine is the single source of truth for both routing and views. There is no separate route configuration file. There is no switch statement in a component deciding what to render based on the URL. The state machine encodes all of that.


formatPlayRouteTransitions — automatic route event wiring

For routing to work, the machine must respond to play.route events (sent by router bridges when the user navigates). Writing these transitions by hand is mechanical:

// Without formatPlayRouteTransitions — verbose and repetitive
states: {
home: {
on: {
"play.route": [
{ guard: ({ event }) => event.to === "#login", target: "login" },
{ guard: ({ event }) => event.to === "#dashboard", target: "dashboard" },
],
},
meta: { route: "/" },
},
// ... repeated for every state
}

formatPlayRouteTransitions from @xmachines/play-xstate generates these transitions automatically from the id and meta.route fields you already have:

import { formatPlayRouteTransitions } from "@xmachines/play-xstate";
const appMachine = setup({
/* ... */
}).createMachine(
formatPlayRouteTransitions({
id: "app",
initial: "home",
states: {
home: { id: "home", meta: { route: "/" } },
login: { id: "login", meta: { route: "/login" } },
dashboard: { id: "dashboard", meta: { route: "/dashboard" } },
},
}),
);

formatPlayRouteTransitions inspects all state nodes with a meta.route and generates the corresponding play.route guard transitions. The machine’s context must include params and query fields (populated by the router bridge when params or query strings are present):

types: {
context: {} as {
params: Record<string, string>;
query: Record<string, string>;
// ... other context fields
},
events: {} as PlayRouteEvent | OtherEvents,
}

Guards — the actor’s authority

Guards are the mechanism by which the actor controls whether a transition occurs. They are pure functions of { context, event } that return a boolean.

const authSetup = setup({
guards: {
isAuthenticated: ({ context }) => context.isAuthenticated,
isAdmin: ({ context }) => context.role === "admin",
},
});

Guards are evaluated by XState before a transition fires. If the guard returns false, the transition does not occur — the machine stays in its current state and the play.route event is discarded. The router bridge then sees that actor.currentRoute has not changed and corrects the URL back to the current valid route.

This is the Actor Authority invariant in practice: the machine decides, infrastructure adjusts.

XMachines provides guard combinators in @xmachines/play-xstate for composing complex conditions:

FunctionWhat it does
composeGuards(...guards)AND — all guards must pass
composeGuardsOr(...guards)OR — any guard must pass
negateGuard(guard)NOT — inverts the guard result
hasContext(key)Checks that a context field is non-null
contextFieldMatches(key, value)Checks a context field against a value
eventMatches(type)Checks the event type

Context — persistent state across transitions

Context is the machine’s persistent data store. It survives transitions and can be read and updated by actions:

// Context is defined in setup({ types })
types: {
context: {} as {
isAuthenticated: boolean;
username: string | null;
loginAttempts: number;
},
},
// Actions mutate context via assign
actions: {
recordLoginFailure: assign({
loginAttempts: ({ context }) => context.loginAttempts + 1,
}),
},

Context is accessed in guards, actions, and when computing routes or views. It is not directly observable from outside the actor via signals — only the derived signals (state, currentRoute, currentView) are public. If you need to expose a context field reactively, add a Signal.Computed to the actor that derives from actor.state.


Snapshots and restoration

A snapshot is a point-in-time serialisation of the machine’s current state and context. XState produces snapshots in a JSON-compatible format:

const snapshot = actor.getSnapshot();
// snapshot.value → current state node name, e.g. "dashboard"
// snapshot.context → current context object
// Stop and serialize
actor.stop();
const saved = JSON.stringify(snapshot);
// Restore
const restored = JSON.parse(saved);
const actor = createPlayer(undefined, { snapshot: restored });
actor.start();
// Actor resumes from where it left off

definePlayer accepts an optional restore argument for this purpose. Restoration is useful for server-side rendering (hydrate with the server’s snapshot), session persistence (resume after page reload), and testing (start from a known mid-flow state).


What state machines replace

Traditional patternState machine equivalentWhy the machine version is better
Boolean flags (isLoading, isError)Explicit states (idle | loading | error)Impossible state combinations are unrepresentable
Switch on URL path in componentmeta.route on state nodesRouting intent lives with the state that owns it
if (user.role === "admin") scattered in componentsGuards on transitionsAuth logic is co-located with the transitions it governs
useEffect on route changes to decide what to rendermeta.view on state nodesView structure is declared alongside the state that owns it
Shared mutable state across componentsContext + signalsMutations are explicit, traceable, and test-covered

See also