Routing Patterns - Play Architecture
Learn how to implement parameter-aware navigation using routing patterns with meta.route URL patterns and play.route events.
Overview
introduces enhanced routing capabilities that allow state machines to handle dynamic routes with parameters while maintaining Actor Authority (INV-01). The router observes actor state changes and syncs the browser URL, but the actor decides which routes are valid through guards.
Key Concepts
Route Marker
States with meta.route property are routable - they can receive play.route events and be navigated to by URL. States without meta.route are internal machine states that don’t correspond to URLs.
states: { dashboard: { meta: { route: '/dashboard', // Marks state as routable view: { component: 'Dashboard' } } }}Play.route Events with Parameters
Use play.route events to navigate with parameters. Unlike xstate.route (which doesn’t support parameters), play.route allows passing data alongside navigation:
// Navigate to profile with userId parameteractor.send({ type: "play.route", to: "/profile/user123", params: { userId: "user123" },});Parameter Patterns in meta.route
Define URL patterns with :param syntax for required parameters and :param? for optional ones:
meta: { route: '/profile/:userId', // Required parameter route: '/settings/:section?', // Optional parameter}Complete Example
See routing-example.ts for a complete runnable example demonstrating all patterns.
Machine Configuration
import { setup } from "xstate";import { definePlayer } from "@xmachines/play-xstate";
const authMachine = setup({ types: { context: {} as { isAuthenticated: boolean; userId: string; routeParams: Record<string, string>; }, events: {} as | { type: "play.route"; to: string; params?: Record<string, string> } | { type: "auth.login"; userId: string }, },}).createMachine({ initial: "login", context: { isAuthenticated: false, userId: "", routeParams: {}, }, states: { login: { id: "login", meta: { route: "/login", view: { component: "LoginView" }, }, on: { "auth.login": { target: "profile", actions: ({ context, event }) => { context.isAuthenticated = true; context.userId = event.userId; }, }, }, },
profile: { id: "profile", meta: { route: "/profile/:userId", view: { component: "ProfileView", userId: (ctx) => ctx.routeParams.userId || ctx.userId, }, }, always: [ { target: "login", guard: ({ context }) => !context.isAuthenticated, // Redirect to login if not authenticated }, ], on: { "play.route": { actions: ({ context, event }) => { if (event.params?.userId) { context.routeParams = { userId: event.params.userId }; } }, }, }, }, },});
const createPlayer = definePlayer({ machine: authMachine });Using the Actor
// Create and start actorconst actor = createPlayer();actor.start();
// Login (transitions to profile)actor.send({ type: "auth.login", userId: "user123" });
// Navigate to different profile with parametersactor.send({ type: "play.route", to: "/profile/user456", params: { userId: "user456" },});
// Parameter extracted and available in viewconst view = actor.currentView.get();console.log(view.userId); // 'user456'Pattern Matching for Dynamic Routes
Routes with parameters use pattern matching to resolve URL paths:
/profile/:userIdmatches/profile/user123,/profile/alice, etc./settings/:section?matches/settings(no section) or/settings/privacy
The router extracts parameters from the URL and passes them in play.route events, which the machine stores in context for view projection.
Guard Placement Architecture
Core Principle: Guards check if you can BE in a state (state entry), not if you can TAKE an event (event handlers).
Pattern 1: Using formatPlayRouteTransitions (RECOMMENDED)
import { formatPlayRouteTransitions } from "@xmachines/play-xstate";
const machineConfig = { initial: "home", context: { isAuthenticated: false }, states: { home: { id: "home", meta: { route: "/" } }, profile: { id: "profile", meta: { route: "/profile/:userId" }, // Always-guard validates state entry always: [ { target: "login", guard: ({ context }) => !context.isAuthenticated, }, ], }, },};
// Utility handles routing infrastructureconst machine = createMachine(formatPlayRouteTransitions(machineConfig));Why this works:
formatPlayRouteTransitionsadds routing infrastructure (event.to matching)- Always-guards handle business logic (authentication, authorization)
- Clear separation: routing layer (infrastructure) vs validation layer (business logic)
Pattern 2: Manual Always-Guards (Advanced)
For advanced users who need full control:
const machine = createMachine({ context: { intendedRoute: null, isAuthenticated: false }, on: { "play.route": { actions: assign({ intendedRoute: ({ event }) => event.to, }), }, }, always: [ { target: "profile", guard: ({ context }) => context.intendedRoute === "#profile" && context.isAuthenticated, reenter: true, }, { target: "login", guard: ({ context }) => context.intendedRoute === "#profile" && !context.isAuthenticated, }, ],});Anti-Pattern: Guards on Events
NEVER do this:
// ❌ WRONG - Guard on event checking event propertyon: { 'play.route': [ { guard: ({ event }) => event.to === "#dashboard", target: "dashboard" } ]}Why this is wrong: The state doesn’t care HOW it was entered (which event triggered it). Guards should validate state invariants: “Can I BE in this state given current context?” not “Can I TAKE this event?”
Correct approach: Use formatPlayRouteTransitions for routing infrastructure, use always-guards for state validation.
Actor Authority in Action
This demonstrates Actor Authority (INV-01) — the state machine controls navigation through guards, not the router. If a guard returns false, the transition is rejected and the URL doesn’t change.
Next Steps
- Multi-Router Integration - Learn about renderer prop pattern
- Integration Example - See complete authentication flow with all patterns
- React + TanStack Router Demo - Production example with all 5 architectural invariants
Learn More
- XState Routes Documentation - Official Stately routing patterns
- RFC Play v1 - Complete architectural specification
- play-router README - Route extraction and tree building