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:
- It is always in exactly one state from a finite set of possible states.
- It transitions between states only in response to events.
- 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 })beforecreateMachine. The type declarations let TypeScript check that your events, context fields, and actions are consistent throughout the machine. - Use
setup.assign(), not the bareassignfrom 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 repetitivestates: { 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:
| Function | What 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 assignactions: { 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 serializeactor.stop();const saved = JSON.stringify(snapshot);
// Restoreconst restored = JSON.parse(saved);const actor = createPlayer(undefined, { snapshot: restored });actor.start();// Actor resumes from where it left offdefinePlayer 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 pattern | State machine equivalent | Why the machine version is better |
|---|---|---|
Boolean flags (isLoading, isError) | Explicit states (idle | loading | error) | Impossible state combinations are unrepresentable |
| Switch on URL path in component | meta.route on state nodes | Routing intent lives with the state that owns it |
if (user.role === "admin") scattered in components | Guards on transitions | Auth logic is co-located with the transitions it governs |
useEffect on route changes to decide what to render | meta.view on state nodes | View structure is declared alongside the state that owns it |
| Shared mutable state across components | Context + signals | Mutations are explicit, traceable, and test-covered |
See also
- Understanding the Actor Model — how the machine definition becomes a live actor
- Understanding TC39 Signals — how the actor’s state is observed by infrastructure
- Getting Started — step-by-step walkthrough building your first machine and actor
- Routing Patterns — worked examples of
meta.routeand guards - @xmachines/play-xstate — full API reference for
definePlayer,PlayerActor, guard combinators - XState v5 documentation — upstream state machine library documentation
- Play RFC — complete architectural specification