Skip to content

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 parameter
actor.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 actor
const actor = createPlayer();
actor.start();
// Login (transitions to profile)
actor.send({ type: "auth.login", userId: "user123" });
// Navigate to different profile with parameters
actor.send({
type: "play.route",
to: "/profile/user456",
params: { userId: "user456" },
});
// Parameter extracted and available in view
const 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/:userId matches /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).

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 infrastructure
const machine = createMachine(formatPlayRouteTransitions(machineConfig));

Why this works:

  • formatPlayRouteTransitions adds 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 property
on: {
'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

Learn More