Multi-Router Integration
How to integrate XMachines Play with the 8 router adapters shipped in this monorepo.
Overview
XMachines provides router adapters for every major JavaScript framework. All adapters implement the same Actor Authority invariant — the state machine guards control navigation, and the router passively observes actor.currentRoute.
There are two integration patterns:
| Pattern | Used by | Key API |
|---|---|---|
| Provider pattern | React (TanStack, React Router), SolidJS (TanStack, SolidJS Router), Vue Router | <PlayRouterProvider actor router routeMap renderer={...} /> |
connectRouter pattern | Vanilla DOM, SvelteKit, Svelte SPA Router | connectRouter({ actor, routeMap, ...adapter }) |
Either pattern can share its router with routes the host owns — see Sharing a Router with the Host.
Pattern 1: Provider Pattern
Used by framework adapters that have a React/Solid/Vue provider context. The PlayRouterProvider owns the bridge lifecycle and calls a renderer prop with the current actor and router.
React + TanStack Router (@xmachines/play-tanstack-react-router)
import { useEffect, useMemo } from "react";import { createRouter, createRootRoute } from "@tanstack/react-router";import { PlayRouterProvider, createRouteMapFromTree } from "@xmachines/play-tanstack-react-router";import { extractMachineRoutes } from "@xmachines/play-router/xstate";import { definePlayer, compose, PlayerActor } from "@xmachines/play-xstate";import { withRouting } from "@xmachines/play-xstate/routing";import { withView } from "@xmachines/play-xstate/view";import { PlayUIProvider, PlayRenderer, type DefineRegistryResult } from "@xmachines/play-react";import type { AnyStateMachine } from "xstate";
declare const appMachine: AnyStateMachine; // your machine, with routes and views// Your catalog, components and actions: defineRegistry(catalog, { components, actions })declare const registryResult: DefineRegistryResult;
// The router needs `withRouting`, and the renderer needs `withView`.const createPlayer = definePlayer({ machine: appMachine, actor: compose(PlayerActor, withRouting, withView),});
function createAppRuntime() { const actor = createPlayer(); actor.start();
const routeTree = extractMachineRoutes(appMachine); const routeMap = createRouteMapFromTree(routeTree); const router = createRouter({ routeTree: createRootRoute(), defaultPreload: "intent" });
return { actor, routeMap, router };}
export function App() { const { actor, routeMap, router } = useMemo(createAppRuntime, []);
useEffect( () => () => { actor.stop(); }, [actor], );
return ( <PlayRouterProvider actor={actor} router={router} routeMap={routeMap} renderer={(currentActor) => ( <PlayUIProvider actor={currentActor} registryResult={registryResult}> <PlayRenderer /> </PlayUIProvider> )} /> );}React + React Router 7/8 (@xmachines/play-react-router)
import { useEffect, useMemo } from "react";import { createBrowserRouter, RouterProvider } from "react-router";import { PlayRouterProvider, createRouteMapFromTree } from "@xmachines/play-react-router";import { extractMachineRoutes } from "@xmachines/play-router/xstate";import { definePlayer, compose, PlayerActor } from "@xmachines/play-xstate";import { withRouting } from "@xmachines/play-xstate/routing";import { withView } from "@xmachines/play-xstate/view";import { PlayUIProvider, PlayRenderer, type DefineRegistryResult } from "@xmachines/play-react";import type { AnyStateMachine } from "xstate";
declare const appMachine: AnyStateMachine; // your machine, with routes and views// Your catalog, components and actions: defineRegistry(catalog, { components, actions })declare const registryResult: DefineRegistryResult;
const createPlayer = definePlayer({ machine: appMachine, actor: compose(PlayerActor, withRouting, withView),});
function createAppRuntime() { const actor = createPlayer(); actor.start();
const routeTree = extractMachineRoutes(appMachine); const routeMap = createRouteMapFromTree(routeTree);
// React Router requires route elements upfront — use a catch-all function RoutedShell() { return ( <PlayUIProvider actor={actor} registryResult={registryResult}> <PlayRenderer /> </PlayUIProvider> ); } const router = createBrowserRouter([{ path: "*", element: <RoutedShell /> }]);
return { actor, routeMap, router };}
export default function App() { const { actor, routeMap, router } = useMemo(createAppRuntime, []);
useEffect( () => () => { actor.stop(); }, [actor], );
return ( <PlayRouterProvider actor={actor} router={router} routeMap={routeMap} renderer={(_, currentRouter) => <RouterProvider router={currentRouter} />} /> );}SolidJS + SolidJS Router (@xmachines/play-solid-router)
import { Router, Route, useNavigate, useLocation, useParams } from "@solidjs/router";import { onCleanup } from "solid-js";import { PlayRouterProvider } from "@xmachines/play-solid-router";import { createRouteMap } from "@xmachines/play-router/xstate";import { extractMachineRoutes, getRoutableRoutes } from "@xmachines/play-router/xstate";import { definePlayer, compose, PlayerActor } from "@xmachines/play-xstate";import { withRouting } from "@xmachines/play-xstate/routing";import { withView } from "@xmachines/play-xstate/view";import { PlayUIProvider, PlayRenderer, type DefineRegistryResult } from "@xmachines/play-solid";import type { AnyStateMachine } from "xstate";
declare const appMachine: AnyStateMachine; // your machine, with routes and views// Your catalog, components and actions: defineRegistry(catalog, { components, actions })declare const registryResult: DefineRegistryResult;
const createPlayer = definePlayer({ machine: appMachine, actor: compose(PlayerActor, withRouting, withView),});const actor = createPlayer();actor.start();
const routeTree = extractMachineRoutes(appMachine);const routes = getRoutableRoutes(routeTree);const routeMap = createRouteMap(appMachine);
// Layout rendered inside Router — has access to navigate/location/params hooksconst Layout = () => { const navigate = useNavigate(); const location = useLocation(); const params = useParams();
onCleanup(() => { actor.stop(); });
return ( <PlayRouterProvider actor={actor} routeMap={routeMap} router={{ navigate, location, params }} renderer={(currentActor) => ( <PlayUIProvider actor={currentActor} registryResult={registryResult}> <PlayRenderer /> </PlayUIProvider> )} /> );};
export default function App() { return ( <Router root={Layout}> {routes.map((route) => ( <Route path={route.fullPath} component={() => null} /> ))} </Router> );}SolidJS + TanStack Solid Router (@xmachines/play-tanstack-solid-router)
import { createRouter, createRootRoute, createRoute, RouterProvider } from "@tanstack/solid-router";import { onCleanup } from "solid-js";import { PlayRouterProvider } from "@xmachines/play-tanstack-solid-router";import { createRouteMap } from "@xmachines/play-router/xstate";import { extractMachineRoutes, getRoutableRoutes } from "@xmachines/play-router/xstate";import { definePlayer, compose, PlayerActor } from "@xmachines/play-xstate";import { withRouting } from "@xmachines/play-xstate/routing";import { withView } from "@xmachines/play-xstate/view";import { PlayUIProvider, PlayRenderer, type DefineRegistryResult } from "@xmachines/play-solid";import type { AnyStateMachine } from "xstate";
declare const appMachine: AnyStateMachine; // your machine, with routes and views// Your catalog, components and actions: defineRegistry(catalog, { components, actions })declare const registryResult: DefineRegistryResult;
const createPlayer = definePlayer({ machine: appMachine, actor: compose(PlayerActor, withRouting, withView),});const actor = createPlayer();actor.start();
const routeTree = extractMachineRoutes(appMachine);const routes = getRoutableRoutes(routeTree);const routeMap = createRouteMap(appMachine);
const rootRoute = createRootRoute({ component: Layout });
// Convert XMachines :param to TanStack $param formatconst tanstackRoutes = routes.map((route) => { const tanstackPath = route.fullPath.replace(/:(\w+)/g, "$$$1"); return createRoute({ getParentRoute: () => rootRoute, path: tanstackPath, component: () => null, });});
const router = createRouter({ routeTree: rootRoute.addChildren(tanstackRoutes) });
function Layout() { onCleanup(() => { actor.stop(); });
return ( <PlayRouterProvider actor={actor} router={router} routeMap={routeMap} renderer={(currentActor) => ( <PlayUIProvider actor={currentActor} registryResult={registryResult}> <PlayRenderer /> </PlayUIProvider> )} /> );}
export default function App() { return <RouterProvider router={router} />;}Vue + Vue Router (@xmachines/play-vue-router)
<template> <PlayRouterProvider :actor="actor" :route-map="routeMap" :router="router" :renderer="renderShell" /></template>
<script setup lang="ts"> import { h } from "vue"; import { createRouter, createWebHistory } from "vue-router"; import { PlayRouterProvider } from "@xmachines/play-vue-router"; import { createRouteMap } from "@xmachines/play-router/xstate"; import { definePlayer, compose, PlayerActor } from "@xmachines/play-xstate"; import { withRouting } from "@xmachines/play-xstate/routing"; import { withView } from "@xmachines/play-xstate/view"; import { PlayUIProvider, PlayRenderer, type DefineRegistryResult } from "@xmachines/play-vue"; import type { AnyStateMachine } from "xstate";
declare const appMachine: AnyStateMachine; // your machine, with routes and views // Your catalog, Vue SFC components and actions: defineRegistry(catalog, { components, actions }) declare const registryResult: DefineRegistryResult;
const createPlayer = definePlayer({ machine: appMachine, actor: compose(PlayerActor, withRouting, withView), }); const actor = createPlayer(); actor.start();
const routeMap = createRouteMap(appMachine);
const router = createRouter({ history: createWebHistory(), routes: [ { path: "/:pathMatch(.*)*", name: "xmachines-play", component: { render: () => h("div") }, }, ], });
const renderShell = () => h(PlayUIProvider, { actor, registryResult }, () => h(PlayRenderer));</script>Pattern 2: connectRouter Pattern
Used by vanilla DOM and Svelte adapters. Call connectRouter directly after creating the actor — no provider component needed.
Vanilla DOM (@xmachines/play-dom-router)
import { createBrowserHistory, createRouter, connectRouter } from "@xmachines/play-dom-router";import { createRouteMap } from "@xmachines/play-router/xstate";import { extractMachineRoutes } from "@xmachines/play-router/xstate";import { definePlayer, compose, PlayerActor } from "@xmachines/play-xstate";import { withRouting } from "@xmachines/play-xstate/routing";import { withView } from "@xmachines/play-xstate/view";import { createPlayUI, type DefineRegistryResult } from "@xmachines/play-dom";import type { AnyStateMachine } from "xstate";
declare const appMachine: AnyStateMachine; // your machine, with routes and views// Your catalog, components and actions: defineRegistry(catalog, { components, actions })declare const registryResult: DefineRegistryResult;
// The router needs `withRouting`, and the renderer below needs `withView`.const createPlayer = definePlayer({ machine: appMachine, actor: compose(PlayerActor, withRouting, withView),});const actor = createPlayer();actor.start();
// Router setupconst routeTree = extractMachineRoutes(appMachine);const routeMap = createRouteMap(appMachine);const history = createBrowserHistory({ window });const router = createRouter({ routeTree, history });
// connectRouter handles bidirectional sync:// - actor.currentRoute changes → browser URL updated// - browser URL changes → play.route event sent to actorconst disconnect = connectRouter({ actor, router, routeMap });
// Renderer setupconst mount = createPlayUI(registryResult);const disconnectRenderer = mount(actor, document.getElementById("app")!);
// Call teardown() when the app ends before the page does, for example when the component// that holds it unmounts. At the end of the page, do nothing: the browser frees the page.// A teardown on `beforeunload` also runs when the page goes into the back/forward cache,// and Back then shows a page that does not respond.function teardown(): void { disconnect(); router.destroy(); disconnectRenderer(); actor.stop();}SvelteKit (@xmachines/play-sveltekit-router)
import type { DefineRegistryResult } from "@xmachines/play-svelte";import { definePlayer, compose, PlayerActor } from "@xmachines/play-xstate";import { withRouting } from "@xmachines/play-xstate/routing";import { withView } from "@xmachines/play-xstate/view";import { connectRouter } from "@xmachines/play-sveltekit-router";import { createRouteMap } from "@xmachines/play-router/xstate";import type { AnyStateMachine } from "xstate";
declare const appMachine: AnyStateMachine; // your machine, with routes and views// Your catalog, Svelte components and actions: defineRegistry(catalog, { components, actions })export declare const registryResult: DefineRegistryResult;
const createPlayer = definePlayer({ machine: appMachine, actor: compose(PlayerActor, withRouting, withView),});
export const actor = createPlayer();actor.start();
export const routeMap = createRouteMap(appMachine);export const disconnectRouter = connectRouter({ actor, routeMap });<script lang="ts"> import { PlayUIProvider, PlayRenderer } from "@xmachines/play-svelte"; import { actor, registryResult } from "./lib/router.js";</script>
<PlayUIProvider {actor} {registryResult}> <PlayRenderer /></PlayUIProvider>Svelte SPA Router (@xmachines/play-svelte-spa-router)
// lib/router.ts — identical pattern to SvelteKit, different importimport { connectRouter } from "@xmachines/play-svelte-spa-router";import { createRouteMap } from "@xmachines/play-router/xstate";import { definePlayer, compose, PlayerActor } from "@xmachines/play-xstate";import { withRouting } from "@xmachines/play-xstate/routing";import { withView } from "@xmachines/play-xstate/view";import type { AnyStateMachine } from "xstate";
declare const appMachine: AnyStateMachine; // your machine, with routes and views
const createPlayer = definePlayer({ machine: appMachine, actor: compose(PlayerActor, withRouting, withView),});
export const actor = createPlayer();actor.start();
export const routeMap = createRouteMap(appMachine);export const disconnectRouter = connectRouter({ actor, routeMap });Sharing a Router with the Host (basePath)
Both patterns give the machine the complete URL space of its router by default. Pass a basePath and the machine owns a prefix only — the host keeps every other route in the same router.
The shape that motivates this is a URL such as /:machineId/play/dashboard. The host resolves :machineId — a TanStack loader, a useParams() call, a SvelteKit load function — and owns that prefix. The meta.route tree of the machine owns everything below it.
Under the Provider Pattern
basePath and basePathParams are reactive props. They need no stable reference, unlike actor, router, and routeMap, and a change moves the live bridge instead of rebuilding it:
import { useLoaderData } from "@tanstack/react-router";import { PlayRouterProvider, type RoutableActor, type RouteMap, type TanStackRouterInstance,} from "@xmachines/play-tanstack-react-router";import { PlayUIProvider, PlayRenderer, type PlayUIProviderProps, type DefineRegistryResult,} from "@xmachines/play-react";
// The actor, the router, the route map, and the registry of the TanStack example abovedeclare const actor: RoutableActor & PlayUIProviderProps["actor"];declare const router: TanStackRouterInstance;declare const routeMap: RouteMap;declare const registryResult: DefineRegistryResult;
function PlayRoute() { const { machineId } = useLoaderData({ from: "/$machineId/play/$" });
return ( <PlayRouterProvider actor={actor} router={router} routeMap={routeMap} // unchanged — the route map stays machine-relative basePath="/:machineId/play" basePathParams={{ machineId }} // the loader resolved this already renderer={(currentActor) => ( <PlayUIProvider actor={currentActor} registryResult={registryResult}> <PlayRenderer /> </PlayUIProvider> )} /> );}
// The "/dashboard" of the machine is now the URL "/abc123/play/dashboard".// The "/" of the machine is now the URL "/abc123/play".Under connectRouter
basePath and basePathParams join the options that the adapter needs already. The
vanilla DOM adapter needs router, as it does in the example above:
import { connectRouter, type RoutableActor, type RouteMap, type VanillaRouter,} from "@xmachines/play-dom-router";
// The actor, the router, and the route map of the vanilla DOM example abovedeclare const actor: RoutableActor;declare const router: VanillaRouter;declare const routeMap: RouteMap;declare const machineId: string; // the host resolved this, for example in a loader
const disconnect = connectRouter({ actor, router, // required: @xmachines/play-dom-router reads its history routeMap, // unchanged — the route map stays machine-relative basePath: "/:machineId/play", basePathParams: { machineId }, // the loader resolved this already});The Svelte adapters take the same two prefix options. They need no router:
@xmachines/play-svelte-spa-router reads an optional window, and
@xmachines/play-sveltekit-router reads an optional location.
What the Prefix Changes
A location outside the prefix belongs to the host. The bridge sends no play.route event there, it runs no corrective navigation there, and it writes no route of its actor there. That silence is what lets the two halves share one router: without it, the unknown-path correction would drag the user off every page the host owns, and so would any route change of the actor that no URL event caused — an after timer, an async guard that settles, a restore of a snapshot. The bridge remembers such a move and writes it when the location comes back under the prefix, so the machine keeps its place.
| Location of the router | basePath = "/abc123/play" |
|---|---|
/abc123/play | the / of the machine → play.route |
/abc123/play/about | the /about of the machine → play.route |
/abc123/play/nope | unknown inside the mount → the URL follows the actor |
/account/billing | a route of the host → the bridge does nothing |
/abc123/playground | another segment → the host’s, the bridge does nothing |
The prefix resolves in advance, because the bridge writes a real browser URL. It rejects every shape that resolves to one concrete path never — a * wildcard, a :param?, a $param (the TanStack spelling; write :param), a query string, and a hash. A base path is a pathname, so a URL scheme is rejected too: write /admin, not https://app.example.com/admin. Every segment that a browser rewrites is rejected on the same rule, the resolved value of a :param included — a dot segment, a backslash, whitespace, and a character that a URL percent-encodes, such as the é of /café — because each one makes the prefix that comes back differ from the prefix that went out, and the machine would go silent with no error. An unresolved :param throws MissingBasePathParamError at construction, rather than falling back to “no prefix yet”: a bridge with no prefix would claim the complete router and start correcting the URLs of the host.
The machine stays authoritative over its own params (INV-01). event.params carries what the pattern of the machine declares and nothing else. The params of the prefix belong to the host, so they travel in no play.route event, and a host param that shares a name with one of the machine can shadow it never. Read the resolved mount from the bridge instead, where it cannot go stale:
import type { RouterBridgeBase } from "@xmachines/play-router";
declare const bridge: RouterBridgeBase; // the bridge of any adapter
bridge.basePath; // "/abc123/play"bridge.basePathParams; // { machineId: "abc123" }A machine that needs the identity of its host takes it through the actor input, where that identity decides which machine runs.
Several Machines Alive at Once
Several machines can be alive together, each mounted at its own prefix, all sharing one router. Every bridge hears every location change, and a location under another machine’s prefix is foreign in exactly the way a route of the host is — so a play.route reaches the machine that owns the URL and no other, and a correction of an unknown path happens only inside the prefix that owns it.
Each bridge can stay connected, as long as each one has its own prefix. A bridge outside its mount keeps the silence in both directions: it sends no play.route, it corrects no URL, and it writes no route of its actor. The machine that the location belongs to is therefore the only one that writes, and an actor that nobody is looking at cannot take the URL from the one on screen. A route that the hidden machine moved to is not lost either: the bridge remembers it, and it writes it when the host navigates back under its prefix.
Two bridges contend when neither prefix separates their halves of the URL. That is the case for two bridges with no prefix, for two with the same prefix, and also for two whose prefixes NEST: a mount at /a and a mount at /a/b both claim /a/b/x, because /a is a prefix of it. Give sibling prefixes — /a/one and /a/two — and no location belongs to two machines. Mount the provider for the machine the host is showing, and let the others keep their state with no bridge attached.
Loading and Unloading Routes
setBasePath() moves where an actor is mounted, and never which actor is mounted. The actor, the route map, and its LRU cache all stay:
import type { RouterBridgeBase } from "@xmachines/play-router";
declare const bridge: RouterBridgeBase; // the bridge of any adapterdeclare const machineId: string;
// The host moved this actor from one region of its URL space to another.bridge.setBasePath("/:region/:machineId/play", { region: "us", machineId });The call also brings the location in step. Nothing else moves the address bar, so a move to a prefix that the URL is not under writes the new mount and keeps the route of the actor: /eu/abc123/play/about becomes /us/abc123/play/about. A location that already lies under the new prefix drives the actor instead, exactly as it does on connect().
An actor never changes identity, so a segment of the prefix that identifies the actor never moves through this method. A new identity is a new actor, and a new actor takes a new bridge, because connect() permits one bridge for each actor. The segments that move here are the ones that locate: a region, a locale, a tenant, a workspace slug.
For a host router that declares real route objects, ask for the list — and drop it again when the machine unloads:
import { extractMachineRoutes, getRouteMappings } from "@xmachines/play-router/xstate";import type { AnyStateMachine } from "xstate";
declare const appMachine: AnyStateMachine; // your machine, with routesdeclare const machineId: string; // "abc123", which the loader resolved
const tree = extractMachineRoutes(appMachine);
// Concrete, for a route that the host adds after a loader resolved the mountgetRouteMappings(tree, { basePath: "/:machineId/play", basePathParams: { machineId } });// [{ stateId: "home", path: "/abc123/play" },// { stateId: "profile", path: "/abc123/play/profile/:username" }, ...]
// The pattern, for a static route declaration of the hostgetRouteMappings(tree, { basePath: "/:machineId/play" });// [{ stateId: "home", path: "/:machineId/play" }, ...]Param note. Under a mount,
@xmachines/play-vue-routerand@xmachines/play-solid-routerresolve each param from the stripped path withURLPattern, and not from the pre-parsed params of their framework: under a prefix the framework matched a route of the host, so those params describe the route of the machine never — and a collision of names carries the value of the host. With no prefix, both keep the parse of their framework whenever it reports at least one param the pattern declares, and every required one. A pattern whose params are ALL optional and a framework that reports none —/settings/:section?under a catch-all of the host — is settled by the PATH:/settingsis the bare form of that pattern, so every optional segment is absent and the params are{}. A path that DOES carry a value, such as/settings/security, still reaches the extraction, because only the extraction reads that value.These branches decide the CALLS of a navigation, and never the availability of the API.
@xmachines/play-urlcarriesurlpattern-polyfillas an ordinary dependency and it uses the nativeURLPatternwhen the runtime has one, so you install no polyfill and you load none. A BAD pattern is a question of the route map:RouteMapcompiles each parameterized route in its constructor and throws anInvalidRoutePatternErrorthere. The routing guide states the pattern language.
Adapter Summary
| Package | Framework | Pattern | Key Import |
|---|---|---|---|
@xmachines/play-dom-router | Vanilla DOM | connectRouter | connectRouter, createRouteMap, createBrowserHistory, createRouter |
@xmachines/play-react-router | React Router 7/8 | Provider | PlayRouterProvider, createRouteMapFromTree |
@xmachines/play-tanstack-react-router | TanStack React Router | Provider | PlayRouterProvider, createRouteMapFromTree |
@xmachines/play-solid-router | SolidJS Router | Provider | PlayRouterProvider, createRouteMap |
@xmachines/play-tanstack-solid-router | TanStack Solid Router | Provider | PlayRouterProvider, createRouteMap |
@xmachines/play-vue-router | Vue Router | Provider | PlayRouterProvider, createRouteMap |
@xmachines/play-sveltekit-router | SvelteKit | connectRouter | connectRouter, createRouteMap |
@xmachines/play-svelte-spa-router | Svelte SPA Router | connectRouter | connectRouter, createRouteMap |
Renderer Packages
Each framework also has a companion renderer package for the view layer:
| Package | Framework | Key API |
|---|---|---|
@xmachines/play-dom | Vanilla DOM | createPlayUI, createRenderer, defineRegistry |
@xmachines/play-react | React | PlayRenderer, defineRegistry |
@xmachines/play-solid | SolidJS | PlayRenderer, defineRegistry |
@xmachines/play-vue | Vue 3 | PlayRenderer, defineRegistry |
@xmachines/play-svelte | Svelte 5 | PlayRenderer, defineRegistry |
Architectural Invariants
All adapters preserve the 5 architectural invariants:
- Actor Authority (INV-01): Machine guards validate all navigation
- Strict Separation (INV-02): Business logic (machine) has zero framework imports
- Atom-Only Reactivity (INV-03): atoms work identically in all adapters
- Passive Infrastructure (INV-04): Router observes actor, never decides
- State-Driven Reset (INV-05): Browser back/forward sends
play.routeevents to actor
Next Steps
- Routing Patterns —
meta.route,play.route,alwaysguards - Examples Index — Complete catalog of runnable demos
Learn More
- Play RFC — Complete architectural specification
- play-router — Route extraction and route maps
- play-dom-router — Vanilla DOM router adapter