Skip to content

@xmachines/play-router

Documentation / @xmachines/play-router

Route tree extraction from XState v5 state machines with routing patterns

BFS graph crawling and bidirectional route lookup enabling Actor Authority over navigation.

Overview

@xmachines/play-router extracts route trees from XState state machines by crawling the state graph using breadth-first traversal. It extracts meta.route paths from state machines and builds hierarchical route trees with bidirectional state ID ↔ path mapping.

It also exports RouterBridgeBase, the shared base class used by framework adapters to implement RouterBridge with consistent actor↔router synchronization behavior.

RouterBridgeBase is the policy point; framework adapters are thin ports that implement only framework-specific navigate/subscribe/unsubscribe behavior.

Per RFC Play v1, this package implements:

  • Actor Authority (INV-01): Routes derive from machine definitions, not external configuration

Routing: Supports meta.route detection, play.route event routing, and pattern matching for dynamic parameters.

Installation

Terminal window
npm install xstate@^5.0.0
npm install @xmachines/play-router

Peer dependencies:

  • xstate ^5.0.0 — State machine runtime

URLPattern polyfill (Node.js < 24 / older browsers):

@xmachines/play-router uses the URLPattern API for dynamic route matching. URLPattern is available natively on Node.js 24+ and modern browsers (Chrome 95+, Firefox 117+, Safari 16.4+).

On environments without native support, load a polyfill before importing this package:

// Entry point — must run before any @xmachines/play-router import
import "urlpattern-polyfill";

Install the polyfill:

Terminal window
npm install urlpattern-polyfill

urlpattern-polyfill is declared as an optional peer dependency. Package managers will not install it automatically — it is the consumer’s responsibility to load it when needed.

Quick Start

import { createMachine } from "xstate";
import { extractMachineRoutes } from "@xmachines/play-router";
// Route pattern (recommended)
const machine = createMachine({
id: "app",
initial: "home",
states: {
home: {
id: "home",
meta: { route: "/", view: { component: "Home" } },
},
dashboard: {
id: "dashboard",
meta: { route: "/dashboard", view: { component: "Dashboard" } },
initial: "overview",
states: {
overview: {
id: "overview",
meta: { route: "/overview", view: { component: "Overview" } },
},
settings: {
id: "settings",
meta: { route: "/settings/:section?", view: { component: "Settings" } }, // Optional parameter
},
},
},
},
});
const tree = extractMachineRoutes(machine);
// Bidirectional lookup
console.log(tree.byPath.get("/dashboard/overview")); // RouteNode
console.log(tree.byId.get("overview")); // RouteNode
// Pattern matching for dynamic routes
const settingsRoute = tree.byPath.get("/settings/profile");
console.log(settingsRoute?.id); // "settings"

Vanilla Browser Example

See examples/vanilla-demo/ for a complete example using vanilla TypeScript with the browser History API.

The demo demonstrates:

  • RouteTree extraction from XState machine meta.route
  • History API integration (pushState, popstate)
  • Bidirectional synchronization (actor ↔ URL)
  • Protected route guards (authentication redirects)
  • Dynamic route parameters (/profile/:userId)

Run the demo:

Terminal window
cd packages/play-router/examples/demo
npm install
npm run dev

Open http://localhost:5174/ and explore:

  1. Login with any username
  2. Navigate between home and profile
  3. Use browser back/forward buttons
  4. Try accessing protected routes directly

Key implementation patterns:

// Extract routes from machine
const routeTree = extractMachineRoutes(authMachine);
const routeMap = createRouteMap(routeTree);
// Actor → URL sync
const watcher = new Signal.subtle.Watcher(() => {
queueMicrotask(() => {
watcher.getPending();
const route = actor.currentRoute.get();
if (route) {
window.history.pushState({}, "", route);
}
watcher.watch(actor.currentRoute);
});
});
// URL → Actor sync (with pattern matching)
window.addEventListener("popstate", () => {
const path = window.location.pathname;
const { to, params } = routeMap.resolve(path);
if (to) {
actor.send({ type: "play.route", to, params });
}
});
// Initial URL handling
const initialPath = window.location.pathname;
if (initialPath !== "/") {
const { to, params } = routeMap.resolve(initialPath);
if (to) {
actor.send({ type: "play.route", to, params });
}
}

This example shows the core routing concepts without framework dependencies, making it ideal for understanding how @xmachines/play-router integrates with browser History API.

Canonical Watcher Lifecycle

Bridge implementations should use one watcher flow:

  1. notify
  2. queueMicrotask
  3. getPending()
  4. read actor route and sync infrastructure state
  5. re-arm with watch(...) or watch()

Watcher notification is one-shot; re-arm is required.

Bridge Cleanup Contract

Bridge teardown must be explicit and deterministic:

  • disconnect/dispose must unwatch signal subscriptions and unhook router listeners.
  • Do not rely on GC-only cleanup guidance.
  • Infrastructure remains passive: bridges observe and forward intents, actors decide validity.

API Reference

extractMachineRoutes()

Main entry point — crawls state machine, extracts routes, builds tree:

const tree = extractMachineRoutes(machine: AnyStateMachine): RouteTree;

Detection:

  • States with meta.route in meta object

Returns: RouteTree with:

  • routes: RouteNode[] - Array of route nodes
  • byPath: Map<string, RouteNode> - URL path → route node
  • byId: Map<string, RouteNode> - State ID → route node

Throws: Error if routes are invalid (malformed paths, missing state IDs, duplicates)

Example:

import { extractMachineRoutes } from "@xmachines/play-router";
const tree = extractMachineRoutes(authMachine);
// Query routes
const loginRoute = tree.byId.get("login");
console.log(loginRoute?.path); // "/login"
const dashboardRoute = tree.byPath.get("/dashboard");
console.log(dashboardRoute?.id); // "dashboard"

crawlMachine()

Low-level BFS traversal of state machine graph:

const visits = crawlMachine(machine: AnyStateMachine): StateVisit[];

Returns: Array of state visits in breadth-first order with:

  • path: string[] - State path (e.g., [“dashboard”, “settings”])
  • parent: StateNode | null - Parent state node
  • node: StateNode - Current state node

Example:

import { crawlMachine } from "@xmachines/play-router";
const visits = crawlMachine(machine);
visits.forEach((visit) => {
console.log("State:", visit.path.join("."));
console.log("Parent:", visit.parent?.id ?? "root");
});

Query Utilities

// Get child routes from state
const children = getNavigableRoutes(tree, "dashboard");
// Check if route exists
const exists = routeExists(tree, "/profile/:userId");

Examples

Route Detection

import { extractMachineRoutes } from "@xmachines/play-router";
import { createMachine } from "xstate";
const machine = createMachine({
initial: "home",
states: {
home: {
id: "home",
meta: { route: "/", view: { component: "Home" } },
},
profile: {
id: "profile",
meta: { route: "/profile/:userId", view: { component: "Profile" } }, // Parameter pattern
},
settings: {
id: "settings",
meta: { route: "/settings/:section?", view: { component: "Settings" } }, // Optional parameter
},
},
});
const tree = extractMachineRoutes(machine);
// Bidirectional mapping
const profileById = tree.byId.get("profile");
console.log(profileById?.path); // "/profile/:userId"
const profileByPath = tree.byPath.get("/profile/user123");
console.log(profileByPath?.id); // "profile"

Hierarchical Route Tree

import { extractMachineRoutes, getNavigableRoutes } from "@xmachines/play-router";
const machine = createMachine({
initial: "app",
states: {
app: {
id: "app",
meta: { route: "/", view: { component: "AppShell" } },
initial: "dashboard",
states: {
dashboard: {
id: "dashboard",
meta: { route: "/dashboard", view: { component: "Dashboard" } },
initial: "overview",
states: {
overview: {
id: "overview",
meta: { route: "/overview", view: { component: "Overview" } },
},
analytics: {
id: "analytics",
meta: { route: "/analytics", view: { component: "Analytics" } },
},
},
},
},
},
},
});
const tree = extractMachineRoutes(machine);
// Get child routes
const dashboardChildren = getNavigableRoutes(tree, "dashboard");
console.log(dashboardChildren.map((r) => r.id)); // ["overview", "analytics"]
// Route inheritance
const analyticsRoute = tree.byId.get("analytics");
console.log(analyticsRoute?.path); // "/dashboard/analytics" (inherited parent path)

Pattern Matching

import { extractMachineRoutes } from "@xmachines/play-router";
const machine = createMachine({
states: {
user: {
id: "user",
meta: { route: "/user/:userId", view: { component: "User" } }, // Required parameter
},
settings: {
id: "settings",
meta: { route: "/settings/:section?", view: { component: "Settings" } }, // Optional parameter
},
},
});
const tree = extractMachineRoutes(machine);
// Pattern matching for actual URLs
const userRoute = tree.byPath.get("/user/user123");
console.log(userRoute?.id); // "user"
const settingsDefault = tree.byPath.get("/settings");
console.log(settingsDefault?.id); // "settings" (optional param)
const settingsProfile = tree.byPath.get("/settings/profile");
console.log(settingsProfile?.id); // "settings" (with param)

Route Configuration

states: {
dashboard: {
id: "dashboard", // Required for bidirectional lookup
meta: {
route: "/dashboard", // URL path - marks state as routable
},
},
}

Alternative Pattern

states: {
dashboard: {
id: "dashboard",
meta: {
route: "/dashboard",
},
},
}

Route Inheritance

states: {
parent: {
id: "parent",
meta: { route: "/parent", view: { component: "Parent" } },
states: {
absolute: {
id: "absolute",
meta: { route: "/absolute", view: { component: "Absolute" } }, // Starts with / → doesn't inherit
},
relative: {
id: "relative",
meta: { route: "relative", view: { component: "Relative" } }, // No leading / → inherits parent
// Final path: "/parent/relative"
},
},
},
}

Dynamic Parameters

meta: {
route: "/profile/:userId", // Required parameter
route: "/settings/:section?", // Optional parameter
route: "/docs/:category/:page", // Multiple parameters
view: { component: "AnyView" },
}

Parameter substitution: Values extracted from context or event params (handled by play-xstate adapter).

Architecture

This package enables Actor Authority (INV-01):

  1. Routes derive from machine: Business logic defines routes in state machine, not external config
  2. BFS traversal: Systematic state discovery ensures all nested states visited
  3. Bidirectional mapping: Fast lookup by path (browser URL) or by ID (state machine)
  4. Build-time validation: Invalid routes throw errors during extraction, not runtime

Enhancements:

  • meta.route detection via state metadata
  • Pattern matching for dynamic routes (:param and :param?)
  • State ID ↔ path bidirectional maps for play.route events

License

Copyright (c) 2016 Mikael Karon. All rights reserved.

This work is licensed under the terms of the MIT license.
For a copy, see https://opensource.org/licenses/MIT.

Classes

Interfaces

Type Aliases

Functions