@xmachines/play-router
API / @xmachines/play-router
Route tree extraction from XState v5 state machines. Part of @xmachines/play Universal Player Architecture.
This package extracts the routes from a machine graph and looks them up in both directions. The Actor therefore keeps the authority over the navigation.
Installation
pnpm add xstate@^5.31.0pnpm add @xmachines/play-routerPeer dependencies:
xstate^5.31.0 — XState v5 state machine runtime
URLPattern polyfill (Node.js < 24 / older browsers):
@xmachines/play-router matches each dynamic route with the URLPattern API. URLPattern is native in Node.js 24+ and in a modern browser (Chrome 95+, Firefox 117+, Safari 16.4+).
In an environment without the native API, load a polyfill before you import this package:
// Entry point — must run before any @xmachines/play-router importimport "urlpattern-polyfill";Install the polyfill:
pnpm add urlpattern-polyfillurlpattern-polyfill is an optional peer dependency. A package manager does not install it for you. Install it and load it yourself when your runtime has no native URLPattern.
Usage
Extract routes from a machine
import { createMachine } from "xstate";import { extractMachineRoutes, createRouteMap } from "@xmachines/play-router";
const machine = createMachine({ id: "app", initial: "home", states: { home: { id: "home", meta: { route: "/" }, }, dashboard: { id: "dashboard", meta: { route: "/dashboard" }, initial: "overview", states: { overview: { id: "overview", meta: { route: "/overview" }, }, settings: { id: "settings", meta: { route: "/settings/:section?" }, // optional parameter }, }, }, profile: { id: "profile", meta: { route: "/profile/:userId" }, // required parameter }, },});
// Build hierarchical route tree with bidirectional mapsconst tree = extractMachineRoutes(machine);
// Path → RouteNodeconst node = tree.byPath.get("/dashboard"); // RouteNode for "dashboard"
// State ID → RouteNodeconst overview = tree.byStateId.get("overview");console.log(overview?.fullPath); // "/overview"
// Build a RouteMap for framework adaptersconst routeMap = createRouteMap(machine);routeMap.getStateIdByPath("/profile/123"); // "profile"routeMap.getPathByStateId("profile"); // "/profile/:userId"Sending play.route events
import { definePlayer } from "@xmachines/play-xstate";import type { PlayRouteEvent } from "@xmachines/play-router";
// machine: your routable machine (states carry meta.route)const actor = definePlayer({ machine })();actor.start();
// Navigate to a state by IDconst event: PlayRouteEvent = { type: "play.route", to: "#dashboard",};actor.send(event);
// Navigate with route parametersactor.send({ type: "play.route", to: "#profile", params: { userId: "123" },});
// Navigate with query parametersactor.send({ type: "play.route", to: "#settings", params: { section: "billing" }, query: { tab: "invoices" },});How to write a RouterBridgeBase adapter
Extend RouterBridgeBase, then implement the three abstract methods for your framework:
import { RouterBridgeBase, createRouteMap } from "@xmachines/play-router";import type { RoutableActor } from "@xmachines/play-router";
// Shape of your framework's router — adjust to its real APItype MyRouter = { navigate(path: string): void; subscribe(handler: (location: { pathname: string; search: string }) => void): () => void; state: { location: { pathname: string } };};
export class MyRouterBridge extends RouterBridgeBase { private unsubscribe: (() => void) | null = null;
constructor( private readonly myRouter: MyRouter, actor: RoutableActor, routeMap: ReturnType<typeof createRouteMap>, ) { super(actor, routeMap); }
// Tell the framework router to navigate to a path protected navigateRouter(path: string): void { this.myRouter.navigate(path); }
// Subscribe to router location changes, call syncActorFromRouter on each protected watchRouterChanges(): void { this.unsubscribe = this.myRouter.subscribe((location) => { this.syncActorFromRouter(location.pathname, location.search); }); }
// Unsubscribe from router location changes protected unwatchRouterChanges(): void { this.unsubscribe?.(); this.unsubscribe = null; }
// Provide the router's current path for initial deep-link sync protected override getInitialRouterPath(): string { return this.myRouter.state.location.pathname; }}
// Usage — myRouter: your framework's router instance;// machine/actor: your routable machine and its started actorconst routeMap = createRouteMap(machine);const bridge = new MyRouterBridge(myRouter, actor, routeMap);bridge.connect();// ...bridge.disconnect();API Summary
Route Extraction
| Export | Description |
|---|---|
extractMachineRoutes(machine) | Converts an XState machine into a RouteTree with the state ID ↔ path maps |
createRouteMap(machine, options?) | Builds a RouteMap directly from a machine. An adapter uses this form |
createRouteMapFromTree(tree, options?) | Builds a RouteMap from a RouteTree that you extracted before |
buildRouteTree(routes) | Builds a RouteTree from an array of RouteInfo objects |
machineToGraph(machine) | Converts a machine into a typed @statelyai/graph Graph, for a graph algorithm |
Route Matching
| Export | Description |
|---|---|
RouteMap | The stateId ↔ path lookup class for both directions. It matches an exact path in O(1) and a pattern in O(k) |
findRouteById(tree, id) | Finds a RouteNode by its state ID |
findRouteByPath(tree, path) | Finds a RouteNode by its URL path. It also matches a dynamic pattern |
Query Utilities
| Export | Description |
|---|---|
getRoutableRoutes(tree) | Returns every routable RouteNode in one flat array |
getNavigableRoutes(tree, stateId) | Returns the child routes that a state can reach, through the hierarchy and through a transition |
routeExists(tree, path) | Tells you if the tree holds a path |
getTransitionReachableRoutes(graph, stateId) | Returns the route paths that a state can reach through an XState transition |
isRouteReachable(graph, fromStateId, toStateId) | Tells you if a transition path is present between two states |
Router Bridge
| Export | Description |
|---|---|
RouterBridgeBase | The abstract base class of each framework router adapter. It implements the RouterBridge protocol |
sanitizePathname(path) | Normalizes a raw pathname. It returns null for a path of more than 2048 characters, and for malformed input |
buildPlayRouteEvent(options) | Builds a PlayRouteEvent from a pathname and a route-map match result |
extractRouteParams(pathname, pattern) | Reads the path parameters of a URL with URLPattern |
extractQuery(search) | Reads the query parameters of a URL search string |
Validation
| Export | Description |
|---|---|
validateRouteFormat(route, stateId) | Asserts that the route path is not empty |
validateStateExists(stateId, stateIds) | Asserts that the machine graph holds the state ID |
detectDuplicateRoutes(routes) | Throws when two states resolve to the same full path |
Key Types
| Export | Description |
|---|---|
RouterBridge | The interface of the connect() and disconnect() lifecycle |
RouteTree | The hierarchical tree, with root, byStateId, byPath, and an optional graph |
RouteNode | One node of the tree, with id, path, fullPath, stateId, children, and parent |
RouteInfo | The flat route descriptor that comes from a state node |
PlayRouteEvent | Routing event { type: "play.route", to, params?, query? } |
RoutableActor | The minimal actor interface that RouterBridgeBase requires: currentRoute, initialRoute, and send(PlayRouteEvent) |
PlayActor | The complete actor interface that PlayRouterProvider uses. It extends RoutableActor with currentView (Routable + Viewable) |
RouteMapping | The { stateId, path } pair that builds a RouteMap |
RouteMapping as BaseRouteMapping | The alias of RouteMapping, for compatibility with an earlier version |
MachineGraph | The typed @statelyai/graph Graph, with MachineNodeData and MachineEdgeData |
WindowLike | The minimal window interface that you can inject for SSR and for a test |
LocationLike | The minimal location interface that you can inject for SSR and for a test |
Errors (subpath @xmachines/play-router/errors)
| Class | Code | When thrown |
|---|---|---|
RouterSyncError | PLAY_ROUTER_SYNC_FAILED | syncActorFromRouter() cannot send a play.route event |
DuplicateBridgeError | PLAY_ROUTER_DUPLICATE_BRIDGE | A second bridge tries to connect to an actor that already has one |
URLPatternUnavailableError | PLAY_ROUTE_MAP_URLPATTERN_UNAVAILABLE | The URLPattern API is absent, and no polyfill is loaded |
InvalidRoutePatternError | PLAY_ROUTE_MAP_INVALID_PATTERN | The URLPattern constructor refuses a route pattern string |
EmptyRoutePathError | PLAY_ROUTE_EMPTY_PATH | A state declares meta.route: "" |
InvalidStateIdError | PLAY_ROUTE_INVALID_STATE_ID | A route names a state ID that the machine graph does not hold |
DuplicateRoutePathError | PLAY_ROUTE_DUPLICATE_PATH | Two or more states share the same URL path |
UnknownStateTypeError | PLAY_ROUTE_UNKNOWN_STATE_TYPE | A state node has an XState .type value that the package does not know |
import { RouterSyncError, DuplicateBridgeError, URLPatternUnavailableError,} from "@xmachines/play-router/errors";
// bridge from the adapter example abovetry { bridge.connect();} catch (err) { if (err instanceof DuplicateBridgeError) { // Actor already bridged — call disconnect() first } else if (err instanceof RouterSyncError) { console.error("Router sync failed:", err.message, err.cause); }}Route Configuration
meta.route patterns
Declare the route of an XState state node in its meta.route field:
states: { home: { id: "home", meta: { route: "/" }, // static route }, profile: { id: "profile", meta: { route: "/profile/:userId" }, // required parameter }, settings: { id: "settings", meta: { route: "/settings/:section?" }, // optional parameter }, docs: { id: "docs", meta: { route: { path: "/docs", title: "Documentation" } }, // object form },}Relative vs absolute paths
A child route that starts with / is absolute, and it does not inherit the path of its parent. A child route without the first / is relative to its nearest routable ancestor:
states: { dashboard: { id: "dashboard", meta: { route: "/dashboard" }, states: { overview: { id: "overview", meta: { route: "/overview" }, // absolute → fullPath: "/overview" }, stats: { id: "stats", meta: { route: "stats" }, // relative → fullPath: "/dashboard/stats" }, }, },}Always use node.fullPath to match a browser URL and to build a route map. Never use node.path for this.
Testing
# Run tests for this packagepnpm --filter @xmachines/play-router test
# Watch modepnpm --filter @xmachines/play-router run test:watch@xmachines/play-router-shared holds a contract test suite of the router bridge, for the
author of an adapter. That suite drives a real actor. Therefore it is one layer above this
package, and @xmachines/play-router keeps no dependency on an actor runtime.
@xmachines/play-router-shared is a private workspace package. Thus only an adapter author
in this repository can use the suite:
import { runBridgeContractTests } from "@xmachines/play-router-shared/test/router-bridge-contract.js";
runBridgeContractTests({ name: "MyRouterBridge", createHarness(initialPath) { // return ContractHarness with bridge, actor, simulateNavigation, getLastNavigatedPath }, createRestoredHarness(routedPath) { // return ContractHarness whose actor is restored to routedPath // while the mock router starts at the machine's initial route },});Related Packages
- @xmachines/play — Core protocol types (
PlayEvent,PlayError) - @xmachines/play-actor — the abstract actor base class (
AbstractActor,Routable). EveryAbstractActorsubclass satisfiesRoutableActorstructurally - @xmachines/play-signals — the TC39 Signals polyfill that observes the actor route
- @xmachines/play-xstate — the XState v5 logic adapter, which works with a route tree
- @xmachines/play-tanstack-router — Shared TanStack Router bridge base (framework-agnostic)
- @xmachines/play-tanstack-react-router — TanStack Router adapter (React)
- @xmachines/play-tanstack-solid-router — TanStack Router adapter (SolidJS)
- @xmachines/play-react-router — React Router v7 adapter
- @xmachines/play-vue-router — Vue Router adapter
- @xmachines/play-solid-router — SolidJS Router adapter
License
MIT — see LICENSE.
Classes
Interfaces
- BuildPlayRouteEventOptions
- LocationLike
- MachineEdgeData
- MachineNodeData
- PlayActor
- PlayRouteEvent
- ResolvedRoutePath
- RoutableActor
- RouteInfo
- RouteMapOptions
- RouteMapping
- RouteMatch
- RouteNode
- RouteObject
- RouterBridge
- RouteTree
- RouteWatcherHandle
- WindowLike
Type Aliases
Functions
- buildPlayRouteEvent
- buildRouteTree
- createRouteMap
- createRouteMapFromTree
- detectDuplicateRoutes
- extractMachineRoutes
- extractQuery
- extractRouteParams
- findRouteById
- findRouteByPath
- getNavigableRoutes
- getRoutableRoutes
- getTransitionReachableRoutes
- isRouteReachable
- machineToGraph
- routeExists
- sanitizePathname
- validateRouteFormat
- validateStateExists