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.

Graph-based route extraction and bidirectional lookup enabling Actor Authority over navigation.

Part of the xmachines-js monorepo.

Installation

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

Peer dependencies:

  • xstate ^5.31.0 — XState v5 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 — consumers must install and load it when their runtime lacks native URLPattern support.

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 type { PlayRouteEvent } from "@xmachines/play-router";
// 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" },
});

Implementing a RouterBridgeBase adapter

Extend RouterBridgeBase and implement the three abstract methods for your framework:

import { RouterBridgeBase } from "@xmachines/play-router";
import type { RoutableActor } from "@xmachines/play-router";
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
const routeMap = createRouteMap(machine);
const bridge = new MyRouterBridge(myRouter, actor, routeMap);
bridge.connect();
// ...
bridge.disconnect();

API Summary

Route Extraction

ExportDescription
extractMachineRoutes(machine)Convert an XState machine to a RouteTree with bidirectional state ID ↔ path maps
createRouteMap(machine, options?)Build a RouteMap directly from a machine (preferred form for adapters)
createRouteMapFromTree(tree, options?)Build a RouteMap from an already-extracted RouteTree
buildRouteTree(routes)Build a RouteTree from an array of RouteInfo objects
machineToGraph(machine)Convert a machine to a typed @statelyai/graph Graph for graph algorithm access

Route Matching

ExportDescription
createRouteMatcher(tree)Create a RouteMatcher that matches URL paths to #stateId values and extracts params
RouteMapBidirectional stateId ↔ path lookup class; supports O(1) exact and O(k) pattern matching
findRouteById(tree, id)Look up a RouteNode by state ID
findRouteByPath(tree, path)Look up a RouteNode by URL path (supports dynamic patterns)

Query Utilities

ExportDescription
getRoutableRoutes(tree)All routable RouteNodes as a flat array
getNavigableRoutes(tree, stateId)Child routes reachable from a state (hierarchical + transition-reachable)
routeExists(tree, path)Check whether a path is registered in the tree
getTransitionReachableRoutes(graph, stateId)Route paths reachable via XState transitions from a state
isRouteReachable(graph, fromStateId, toStateId)Check whether a transition path exists between two states

Router Bridge

ExportDescription
RouterBridgeBaseAbstract base class for framework router adapters; implements RouterBridge protocol
sanitizePathname(path)Normalize a raw pathname; returns null for paths > 2048 chars or malformed input
buildPlayRouteEvent(options)Build a PlayRouteEvent from a pathname + route-map match result
extractRouteParams(pathname, pattern)Extract path parameters from a URL using URLPattern
extractQuery(search)Extract query parameters from a URL search string

Validation

ExportDescription
validateRouteFormat(route, stateId)Assert route path is non-empty
validateStateExists(stateId, stateIds)Assert a state ID is present in the machine graph
detectDuplicateRoutes(routes)Throw if any two states share the same URL path

Key Types

ExportDescription
RouterBridgeInterface for connect() / disconnect() lifecycle
RouteTreeHierarchical tree with root, byStateId, byPath, and optional graph
RouteNodeSingle node in the tree with id, path, fullPath, stateId, children, parent
RouteInfoFlat route descriptor extracted from a state node
PlayRouteEventRouting event { type: "play.route", to, params?, query? }
RoutableActorMinimal actor interface required by RouterBridgeBasecurrentRoute, initialRoute, and send(PlayRouteEvent)
PlayActorFull actor interface used by PlayRouterProvider — extends RoutableActor with currentView (Routable + Viewable)
RouteMapping{ stateId, path } pair used to build a RouteMap
RouteMapping as BaseRouteMappingAlias for RouteMapping (backwards-compat re-export)
MachineGraphTyped @statelyai/graph Graph with MachineNodeData / MachineEdgeData
WindowLikeInjectable minimal window interface for SSR / testing
LocationLikeInjectable minimal location interface for SSR / testing

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

ClassCodeWhen thrown
RouterSyncErrorPLAY_ROUTER_SYNC_FAILEDsyncActorFromRouter() fails to 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_UNAVAILABLEURLPattern API is absent and no polyfill is loaded
InvalidRoutePatternErrorPLAY_ROUTE_MAP_INVALID_PATTERNA route pattern string is rejected by the URLPattern constructor
EmptyRoutePathErrorPLAY_ROUTE_EMPTY_PATHA state declares meta.route: ""
InvalidStateIdErrorPLAY_ROUTE_INVALID_STATE_IDA route references a state ID not in the machine graph
DuplicateRoutePathErrorPLAY_ROUTE_DUPLICATE_PATHTwo or more states share the same URL path
UnknownStateTypeErrorPLAY_ROUTE_UNKNOWN_STATE_TYPEA state node has an unrecognised XState .type value
import {
RouterSyncError,
DuplicateBridgeError,
URLPatternUnavailableError,
} from "@xmachines/play-router/errors";
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

Routes are declared on XState state nodes via the 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

Child routes with a leading / are absolute (do not inherit the parent path). Without a leading /, they resolve relative to their 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 (never node.path) for browser URL matching and route map construction.

Testing

Terminal window
# Run tests for this package
npm test -w @xmachines/play-router
# Watch mode
npm run test:watch -w @xmachines/play-router

The package exports a router bridge contract test suite for adapter authors:

import { runBridgeContractTests } from "@xmachines/play-router/test/contract.js";
runBridgeContractTests({
name: "MyRouterBridge",
createHarness(initialPath) {
// return ContractHarness with bridge, actor, simulateNavigation, getLastNavigatedPath
},
});

License

MIT — see LICENSE.

Classes

Interfaces

Type Aliases

Functions

References

BaseRouteMapping

Renames and re-exports RouteMapping