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-view";
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 ReadonlyAtom) derives this path and emits it. The router bridge reads it and updates the URL.
meta.view is a ViewMeta — a @xmachines/json-render-core spec object describing what to render. The actor derives a PlaySpec from it, and that derived spec is what a renderer receives. Use typedSpec(...) from @xmachines/play-view to type-check the spec literal at the definition site (XState’s meta is untyped). When the machine enters a state, actor.currentView is updated with the derived spec. The renderer reads it and projects it through framework components.
The machine’s whole context is available to every view through the /context projection: the derived spec’s state carries context: <machine context>. Specs read it with ordinary state expressions — { $state: "/context/username" } in props, visible conditions, or repeat.statePath. The subtree is read-only: context changes only through machine events, and a $bindState/setState write under /context throws. URL data lives at its own paths (/context/params/…, /context/query/…, written into context by formatPlayRouteTransitions), so a URL param can never shadow a machine-owned field. When validating specs with tools like validateSpec, validate the derived view (actor.currentView.get()) — its state honestly describes the store contents — not the raw meta.view. A context change re-emits the view with the same viewKey; providers respond by refreshing /context in the live store, not by remounting the UI.
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.
A layout and its pages: outlets
Without outlets, the deepest active state that declares a view wins, and a view on a parent state stays off the screen. Declare outlets in the view of the parent to show the parent view AND the view of its active child. Each outlet names an element of the parent view, and the view of the child goes into that element:
const shell = typedSpec({ root: "shell", state: { collapsed: false }, elements: { shell: { type: "Shell", props: {}, children: ["title"], slots: { sidebar: [] } }, title: { type: "Heading", props: { text: "App" }, children: [] }, }, outlets: { menu: { element: "shell", slot: "sidebar" }, // the region "menu" "*": { element: "shell" }, // every other child },});
const appMachine = setup({/* ... */}).createMachine({ id: "app", type: "parallel", meta: { view: shell }, states: { menu: { initial: "open", states: { open: { meta: { view: menuView } } } }, page: { initial: "home", states: { home: { meta: { route: "/", view: homeView } }, search: { meta: { route: "/search", view: searchView } }, }, }, },});- The key of an outlet is the key of a child state in
states, or"*"for each active child that has no outlet of its own. A child with neither stays off the screen. - A compound state fills one outlet with its active child. A parallel state fills one outlet for each region, in the order of
states. slotnames a named slot of the element. Omit it to append the child view to thechildrenof the element. The component of the element renders the slot, as the renderer of your framework describes.- The view of a child is the deepest view in its active subtree, and a child view can declare
outletsof its own for a nested layout. A parallel state without outlets shows the region of the URL when that region has a view, and its first region that has a view otherwise.
The derived spec holds the elements of every composed view. The keys of the outer view stay as you wrote them, and each key of a child view gets the id of its state as a prefix, for example app.page.search/root, so two views never collide.
The viewKey of a composed view is the id of the outermost state that declares outlets. A change of the page therefore keeps the store of the layout: the collapsed value in the example above does not change. The first time that a page shows, the provider writes the keys of its authored state that the store does not hold. A page that comes back finds the values that it left. All the pages of one layout share one store, so two pages that declare the same key share one value: give each page its own keys. When two composed views declare the same state key, the outer view wins, and the library writes a warning.
An outlet that names an element that its view does not hold throws InvalidViewOutletError. The actor reports it through its onError option, and it keeps the last good view.
handler decides whether the layout survives a navigation. Under the default handler: "root", a navigation exits and enters every active state below the root, and a parallel state then puts its other regions back in their initial state. Declare handler: "local" or "both" on a routed state inside a region, as Re-entry on a navigation describes.
The URL comes from the routed region. In a parallel machine, the URL comes from the first region whose active states declare a meta.route, so the menu region can come before the page region. Keep the routes of one layout in one region. The URL shows the route of the first routed region only, so a navigation to a route of a second routed region never shows in the address bar.
CAUTION: a second routed region moves without its URL. Take a page region with / and /search, and a drawer region after it with /drawer and /drawer/settings:
| Step | page | drawer | URL |
|---|---|---|---|
| start | home | closed | / |
a navigation to #settings | home | settings | / |
a navigation to #search | search | closed | /search |
- The navigation to
#settingsmoves the drawer, and the URL stays/. The browser adds no history entry, BACK does not close the drawer, and a reload or a shared link loses the drawer. - A deep link to
/drawer/settingsmoves the drawer too, but the actor derives the URL/from thepageregion, so the address bar does not keep the path of the drawer. - The navigation to
#searchputs the drawer back toclosed, because the defaulthandler: "root"exits and enters every region.handler: "local"keeps it.
A URL path has one hierarchy, and it cannot hold the branches of two regions. Keep the other regions unrouted. When the position of such a region must survive a reload or a shared link, write it as a query value of the routed region, for example /search?drawer=settings. The generated play.route transition assigns the query to context.query, the URL carries context.query, and your machine moves the region to match the value.
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/routing";
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.
Compose a condition with the combinators of XState:
| Function | What it does |
|---|---|
and([g1, g2]) | AND — every guard must pass |
or([g1, g2]) | OR — one guard must pass |
not(g) | NOT — it inverts the guard |
Each combinator resolves a guard NAME against the guards of setup(), so a name that the map does not hold fails to compile.
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 atoms — only the derived atoms (state, currentRoute, currentView) are public. If you need to expose a context field reactively, add a computed atom 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 + atoms | Mutations are explicit, traceable, and test-covered |
See also
- The Actor Model — how the machine definition becomes a live actor
- Atoms — how the actor’s state is observed by infrastructure
- Getting Started — step-by-step walkthrough building your first machine and actor
- Views — which state supplies the view, how a layout holds its pages, and when a renderer keeps the UI
- Routing Patterns — worked examples of
meta.routeand guards - @xmachines/play-xstate — full API reference for
definePlayer,PlayerActor, and the routing utilities - XState v5 documentation — upstream state machine library documentation
- Play RFC — complete architectural specification