Skip to content

@xmachines/play-router

API / @xmachines/play-router

Route tree extraction from XState v5 state machines. Part of @xmachines/play Universal Player Architecture.

License: MIT Version

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

Terminal window
pnpm add xstate@^5.31.0
pnpm add @xmachines/play-router

Peer 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 import
import "urlpattern-polyfill";

Install the polyfill:

Terminal window
pnpm add urlpattern-polyfill

urlpattern-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 maps
const tree = extractMachineRoutes(machine);
// Path → RouteNode
const node = tree.byPath.get("/dashboard"); // RouteNode for "dashboard"
// State ID → RouteNode
const overview = tree.byStateId.get("overview");
console.log(overview?.fullPath); // "/overview"
// Build a RouteMap for framework adapters
const 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 ID
const event: PlayRouteEvent = {
type: "play.route",
to: "#dashboard",
};
actor.send(event);
// Navigate with route parameters
actor.send({
type: "play.route",
to: "#profile",
params: { userId: "123" },
});
// Navigate with query parameters
actor.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 API
type 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 actor
const routeMap = createRouteMap(machine);
const bridge = new MyRouterBridge(myRouter, actor, routeMap);
bridge.connect();
// ...
bridge.disconnect();

API Summary

Route Extraction

ExportDescription
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

ExportDescription
RouteMapThe 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

ExportDescription
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

ExportDescription
RouterBridgeBaseThe 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

ExportDescription
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

ExportDescription
RouterBridgeThe interface of the connect() and disconnect() lifecycle
RouteTreeThe hierarchical tree, with root, byStateId, byPath, and an optional graph
RouteNodeOne node of the tree, with id, path, fullPath, stateId, children, and parent
RouteInfoThe flat route descriptor that comes from a state node
PlayRouteEventRouting event { type: "play.route", to, params?, query? }
RoutableActorThe minimal actor interface that RouterBridgeBase requires: currentRoute, initialRoute, and send(PlayRouteEvent)
PlayActorThe complete actor interface that PlayRouterProvider uses. It extends RoutableActor with currentView (Routable + Viewable)
RouteMappingThe { stateId, path } pair that builds a RouteMap
RouteMapping as BaseRouteMappingThe alias of RouteMapping, for compatibility with an earlier version
MachineGraphThe typed @statelyai/graph Graph, with MachineNodeData and MachineEdgeData
WindowLikeThe minimal window interface that you can inject for SSR and for a test
LocationLikeThe minimal location interface that you can inject for SSR and for a test

Errors (subpath @xmachines/play-router/errors)

ClassCodeWhen thrown
RouterSyncErrorPLAY_ROUTER_SYNC_FAILEDsyncActorFromRouter() cannot send a play.route event
DuplicateBridgeErrorPLAY_ROUTER_DUPLICATE_BRIDGEA second bridge tries to connect to an actor that already has one
URLPatternUnavailableErrorPLAY_ROUTE_MAP_URLPATTERN_UNAVAILABLEThe URLPattern API is absent, and no polyfill is loaded
InvalidRoutePatternErrorPLAY_ROUTE_MAP_INVALID_PATTERNThe URLPattern constructor refuses a route pattern string
EmptyRoutePathErrorPLAY_ROUTE_EMPTY_PATHA state declares meta.route: ""
InvalidStateIdErrorPLAY_ROUTE_INVALID_STATE_IDA route names a state ID that the machine graph does not hold
DuplicateRoutePathErrorPLAY_ROUTE_DUPLICATE_PATHTwo or more states share the same URL path
UnknownStateTypeErrorPLAY_ROUTE_UNKNOWN_STATE_TYPEA 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 above
try {
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

Terminal window
# Run tests for this package
pnpm --filter @xmachines/play-router test
# Watch mode
pnpm --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
},
});

License

MIT — see LICENSE.

Classes

Interfaces

Type Aliases

Functions