@xmachines/play-tanstack-react-router
API / @xmachines/play-tanstack-react-router
TanStack Router (React) adapter for XMachines Play. It keeps the browser URL and the actor state in step through passive infrastructure.
Installation
pnpm add @xmachines/play-tanstack-react-routerPeer dependencies. Install them separately:
pnpm add @tanstack/react-router react react-dom xstateThe adapter requires:
@tanstack/react-router^1.168.8react^18.0.0or^19.0.0react-dom^18.0.0or^19.0.0xstate^5.31.0
Usage
PlayRouterProvider — React component (recommended)
PlayRouterProvider is the primary integration point. It creates a TanStackReactRouterBridge on mount. It keeps the bridge connected for the life of the component. It disconnects the bridge on unmount.
import { useMemo, useEffect, useState } from "react";import { createMachine } from "xstate";import { createRouter, createRootRoute } from "@tanstack/react-router";import { PlayRouterProvider, createRouteMap } from "@xmachines/play-tanstack-react-router";import { definePlayer, formatPlayRouteTransitions } from "@xmachines/play-xstate";import { useSignalEffect } from "@xmachines/play-react";
// Any machine whose states declare `meta.route` (plus an explicit `id`) is routable.// formatPlayRouteTransitions() generates the root-level `play.route` handlers that// let the bridge drive the machine from URL changes.const machine = createMachine( formatPlayRouteTransitions({ id: "app", initial: "home", states: { home: { id: "home", meta: { route: "/" } }, about: { id: "about", meta: { route: "/about" } }, }, }),);
const createPlayer = definePlayer({ machine });type AppActor = ReturnType<typeof createPlayer>;
// Minimal shell: mirrors the actor's route signal into React state. A full app// renders <PlayUIProvider> + <PlayRenderer> from @xmachines/play-react here// instead — see examples/demo for the complete Shell.function Shell({ actor }: { actor: AppActor }) { const [route, setRoute] = useState(actor.currentRoute.get()); useSignalEffect(() => setRoute(actor.currentRoute.get()), [actor]); return <p>Current route: {route}</p>;}
function createAppRuntime() { const actor = createPlayer(); actor.start();
const routeMap = createRouteMap(machine); const rootRoute = createRootRoute(); const router = createRouter({ routeTree: rootRoute });
return { actor, routeMap, router };}
export function App() { // All three props must be stable references — memoize to avoid reconnecting on every render const { actor, routeMap, router } = useMemo(createAppRuntime, []);
useEffect(() => () => actor.stop(), [actor]);
return ( <PlayRouterProvider actor={actor} router={router} routeMap={routeMap} renderer={(currentActor) => <Shell actor={currentActor} />} /> );}Stable references:
actor,router, androuteMapmust stay stable across the renders. If one prop gets a new identity, the bridge disconnects, then it connects again. UseuseMemoto create each prop one time.
TanStackReactRouterBridge — the bridge class
Use the bridge directly when you do not need the React wrapper, or when you integrate it with a custom lifecycle:
import { createRouter, createRootRoute } from "@tanstack/react-router";import { definePlayer } from "@xmachines/play-xstate";import { TanStackReactRouterBridge, createRouteMap } from "@xmachines/play-tanstack-react-router";import { machine } from "./machine.js"; // the routable machine from the example above
const router = createRouter({ routeTree: createRootRoute() });const actor = definePlayer({ machine })();actor.start();const routeMap = createRouteMap(machine);
const bridge = new TanStackReactRouterBridge(router, actor, routeMap);bridge.connect();
// Cleanup when donebridge.disconnect();API Summary
TanStackReactRouterBridge
This class extends RouterBridgeBase from @xmachines/play-router. It keeps the actor state signals and the TanStack Router history in step, in both directions.
class TanStackReactRouterBridge extends RouterBridgeBase { constructor(router: TanStackRouterLike, actor: RoutableActor, routeMap: RouteMap);
connect(): void; // Start sync; subscribe to router.history and actor signals disconnect(): void; // Stop sync; unsubscribe all listeners}The bridge subscribes to router.history, not to router.subscribe("onBeforeLoad"). Therefore the bridge also receives a browser BACK or FORWARD navigation (a popstate event) when no <RouterProvider> is mounted.
PlayRouterProvider
This React component wraps TanStackReactRouterBridge in a useEffect lifecycle.
interface PlayRouterProviderProps<TActor> { actor: TActor; // Must be stable router: TanStackRouterInstance; // Must be stable routeMap: RouteMap; // Must be stable renderer: (actor: TActor, router: TanStackRouterInstance) => ReactNode;}TanStackRouterLike
The structural type of the router instance. It accepts every object that has the necessary navigate and history shape. A test can therefore use a stub in place of a complete TanStack Router:
type TanStackRouterLike = { navigate(args: { to: string }): void; load?(): void | Promise<void>; history: { location: { pathname: string; search?: string }; subscribe( handler: (event: { location: { pathname: string; search?: string } }) => void, ): () => void; };};RouteNavigateEvent
The event that the bridge sends to the actor when the browser navigates:
interface RouteNavigateEvent { readonly type: "route.navigate"; readonly path: string; // e.g. "/dashboard" or "/posts/123"}Re-exported from @xmachines/play-router
// Route map constructionRouteMapcreateRouteMap(machine, options?): RouteMapcreateRouteMapFromTree(routeTree): RouteMapextractMachineRoutes(machine): RouteTree
// Typestype RouteMapOptionstype RouteMappingtype RouterBridgetype PlayRouteEventHow It Works
The bridge implements the Passive Infrastructure invariant from the XMachines RFC:
- Actor → Router: when the
actor.currentRoutesignal changes, the bridge callsrouter.navigate({ to: path }). The URL then shows the new actor state. - Router → Actor: when
router.history.subscribefires, the bridge sends aplay.routeevent to the actor. A link click, a BACK or FORWARD button, and a call tohistory.pushStateeach cause this. The guards of the actor decide if the navigation is valid. The router never enforces the business logic. - Circular update prevention: the
lastSyncedPathguard stops a return update. An actor-to-router navigation therefore does not cause an unnecessary router-to-actor send. - Deep-link and restore: on
connect(), the bridge readsrouter.history.location.pathname. That value showswindow.locationat once, beforerouter.load()runs. The bridge then makes a decision: it sets the actor state from the URL (a deep link), or it writes the restored route of the actor to the URL (a snapshot restore).
Testing
Run tests for this package in isolation:
pnpm --filter @xmachines/play-tanstack-react-router testFrom the monorepo root:
pnpm testTests cover RouterBridge protocol compliance, actor ↔ router bidirectional sync, circular update prevention, deep-link and snapshot-restore scenarios, and PlayRouterProvider lifecycle (mount/unmount/reconnect).
Demo
examples/demo/ holds a runnable demo of the React and TanStack Router integration. Run it from the repository root:
pnpm installpnpm --filter @xmachines/play-tanstack-react-router-demo run devThen open http://localhost:3011.
The demo shows actor-authoritative routing with a shared auth machine. TanStack Router updates the URL. PlayRouterProvider converts the update into a play.route event. The guards of the actor then permit the access, or they refuse it.
License
MIT — see LICENSE.
@xmachines/play-tanstack-react-router
TanStack Router adapter for the XMachines Play architecture. It keeps the browser URL and the actor state in step through passive infrastructure.
Classes
Interfaces
- PlayActor
- PlayRouteEvent
- PlayRouterProviderBaseProps
- PlayRouterProviderProps
- RouteMapOptions
- RouteMapping
RouteNavigateEvent- RouterBridge