Routing Patterns
How the authMachine uses meta.route, play.route events, formatPlayRouteTransitions, and always guards to implement actor-authoritative URL routing.
Overview
XMachines inverts the usual routing model. The actor owns navigation — its guards decide which states are valid. The router is passive infrastructure that observes actor.currentRoute and keeps the browser URL in sync. This is Actor Authority (INV-01).
Every routing interaction starts with a play.route event sent to the actor. If the machine’s guards allow the transition, the state changes and actor.currentRoute updates. The router bridge sees the new signal value and pushes a history entry. The URL never changes unless the actor approves.
Key Concepts
meta.route — Marking States as Routable
Add a meta.route URL template to any state to make it routable:
states: { home: { id: "home", // required: used as the play.route target ("#home") meta: { route: "/", // absolute URL path }, }, dashboard: { id: "dashboard", meta: { route: "/dashboard", // absolute URL path }, states: { overview: { id: "dashboard-overview", meta: { route: "overview", // RELATIVE — resolves to /dashboard/overview }, }, }, }, profile: { id: "profile", meta: { route: "/profile/:username", // required parameter }, }, settings: { id: "settings", meta: { route: "/settings/:section?", // optional parameter }, },}Rules:
- Absolute paths start with
/. - Relative paths (no leading
/) are resolved against the parent state’s route. - Parameter segments use
:param(required) and:param?(optional). - Every routable state must have an
id—formatPlayRouteTransitionsthrowsMissingStateIdErrorotherwise.
formatPlayRouteTransitions — Auto-Generating Route Handlers
Instead of hand-writing play.route event handlers for every routable state, wrap your machine config with formatPlayRouteTransitions:
import { setup } from "xstate";import { definePlayer, formatPlayRouteTransitions } from "@xmachines/play-xstate";import type { PlayRouteEvent } from "@xmachines/play-router";
interface AuthContext { isAuthenticated: boolean; username: string | null; params: Record<string, string>; // required for formatPlayRouteTransitions query: Record<string, string>; // required for formatPlayRouteTransitions}
const authSetup = setup({ types: { context: {} as AuthContext, events: {} as | PlayRouteEvent | { type: "auth.login"; username: string } | { type: "auth.logout" }, input: {} as Partial<AuthContext> | undefined, },});
const authMachine = authSetup.createMachine( formatPlayRouteTransitions({ id: "auth", initial: "home", context: ({ input }) => ({ isAuthenticated: input?.isAuthenticated ?? false, username: input?.username ?? null, params: input?.params ?? {}, query: input?.query ?? {}, }), states: { home: { id: "home", meta: { route: "/" } }, about: { id: "about", meta: { route: "/about" } }, login: { id: "login", meta: { route: "/login" } }, profile: { id: "profile", meta: { route: "/profile/:username" } }, }, }),);formatPlayRouteTransitions generates root-level handlers equivalent to:
on: { "play.route": [ { target: ".home", guard: ({ event }) => event.to === "#home", reenter: true, actions: assign({ params, query }) }, { target: ".about", guard: ({ event }) => event.to === "#about", reenter: true, actions: assign({ params, query }) }, { target: ".login", guard: ({ event }) => event.to === "#login", reenter: true, actions: assign({ params, query }) }, { target: ".profile", guard: ({ event }) => event.to === "#profile", reenter: true, actions: assign({ params, query }) }, ],}play.route Events — Navigation
To navigate, send a play.route event with to: "#stateId":
// Navigate to homeactor.send({ type: "play.route", to: "#home" });
// Navigate to profile — params are stored in context and used to resolve the URLactor.send({ type: "play.route", to: "#profile", params: { username: "alice" },});
// actor.currentRoute.get() → "/profile/alice"
// Navigate with query stringactor.send({ type: "play.route", to: "#settings", params: { section: "privacy" }, query: { tab: "advanced" },});to always uses "#stateId" format — the state’s id field prefixed with #. Do not pass URL paths here.
always Guards — Protected Routes
Use XState always transitions to protect states. If the guard fires, the machine redirects before the state is fully entered:
dashboard: { id: "dashboard", meta: { route: "/dashboard" }, always: { // If not authenticated, redirect to login immediately guard: ({ context }) => !context.isAuthenticated, target: "login", },},profile: { id: "profile", meta: { route: "/profile/:username" }, always: { guard: ({ context }) => !context.isAuthenticated, target: "login", },},Why always and not event guards? Guards on events check “can I TAKE this transition?”. always guards check “can I BE in this state?” — the correct invariant for authentication. The actor enforces the guard even on direct URL access (browser back/forward or deep link), because the router sends a play.route event which triggers the always guard.
Root-Level Event Handlers
Domain events placed at the root on: level are handled from any state:
const authMachine = authSetup.createMachine( formatPlayRouteTransitions({ // ... on: { "auth.login": { target: ".dashboard", guard: ({ context }) => !context.isAuthenticated, actions: authSetup.assign({ isAuthenticated: true, username: ({ event }) => (event.type === "auth.login" ? event.username : null), }), }, "auth.logout": { target: ".home", guard: ({ context }) => context.isAuthenticated, actions: authSetup.assign({ isAuthenticated: false, username: null, }), }, }, states: { /* ... */ }, }),);Complete Actor Usage
const createPlayer = definePlayer({ machine: authMachine });const actor = createPlayer();actor.start();
// Initial stateconsole.log(actor.currentRoute.get()); // "/"
// Navigate to aboutactor.send({ type: "play.route", to: "#about" });console.log(actor.currentRoute.get()); // "/about"
// Attempt protected route (redirect fires because isAuthenticated=false)actor.send({ type: "play.route", to: "#dashboard" });console.log(actor.getSnapshot().value); // "login" — guard redirected
// Login firstactor.send({ type: "auth.login", username: "alice" });// auth.login root handler transitions to dashboardconsole.log(actor.getSnapshot().value); // "dashboard"console.log(actor.getSnapshot().context.username); // "alice"
// Navigate to profile with paramsactor.send({ type: "play.route", to: "#profile", params: { username: "alice" } });console.log(actor.currentRoute.get()); // "/profile/alice"
actor.stop();Vanilla Router Setup
To sync the browser URL with actor.currentRoute, use @xmachines/play-dom-router:
import { createBrowserHistory, createRouter, connectRouter, createRouteMap,} from "@xmachines/play-dom-router";
// createRouteMap extracts meta.route declarations from the machineconst routeMap = createRouteMap(authMachine);
const history = createBrowserHistory({ window });const router = createRouter({ routeTree: authMachine, history });
// connectRouter handles all bidirectional sync:// - actor.currentRoute signal → browser URL (history.push)// - browser URL changes → play.route event sent to actorconst disconnect = connectRouter({ actor, router, routeMap });
// Cleanup on unloadwindow.addEventListener("beforeunload", () => { disconnect(); router.destroy();});React Router Setup
For React, use @xmachines/play-react-router or @xmachines/play-tanstack-react-router:
import { PlayRouterProvider, createRouteMapFromTree } from "@xmachines/play-tanstack-react-router";import { extractMachineRoutes } from "@xmachines/play-router";
const routeTree = extractMachineRoutes(authMachine);const routeMap = createRouteMapFromTree(routeTree);
function App() { return <PlayRouterProvider actor={actor} router={router} routeMap={routeMap} />;}Architectural Invariants
| Invariant | Description |
|---|---|
| Actor Authority (INV-01) | Guards on the machine validate every navigation. The router cannot change state directly. |
| Passive Infrastructure (INV-04) | The router observes actor.currentRoute — it never decides where to go. |
| State-Driven Reset (INV-03) | Browser back/forward sends play.route events to the actor. History is driven by actor state. |
| Strict Separation (INV-02) | The machine has zero framework imports. Guards, actions, and context are pure TypeScript. |
Next Steps
- Multi-Router Integration — All 8 router adapters and the two integration patterns
- Examples Index — Complete catalog of runnable demos
Learn More
- Play RFC — Complete architectural specification
- play-router — Route extraction and tree building
- play-dom-router — Vanilla DOM bindings