@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-router @xmachines/play-router @xmachines/core @xmachines/play @xmachines/play-atom@xmachines/play-router names @xmachines/core, @xmachines/play and @xmachines/play-atom as peers, so the command installs them with this package.
Peer dependencies. Install them separately:
pnpm add @tanstack/react-router react react-domThe adapter requires:
@tanstack/react-router^1.168.8react^18.0.0or^19.0.0react-dom^18.0.0or^19.0.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 } from "@xmachines/play-tanstack-react-router";import { createRouteMap } from "@xmachines/play-router/xstate";import { definePlayer, compose, PlayerActor } from "@xmachines/play-xstate";import { formatPlayRouteTransitions } from "@xmachines/play-xstate/routing";import { withRouting } from "@xmachines/play-xstate/routing";import { useAtomEffect } 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, actor: compose(PlayerActor, withRouting) });type AppActor = ReturnType<typeof createPlayer>;
// Minimal shell: mirrors the actor's route atom 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()); useAtomEffect([actor.currentRoute], () => 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, compose, PlayerActor } from "@xmachines/play-xstate";import { withRouting } from "@xmachines/play-xstate/routing";import { TanStackReactRouterBridge } from "@xmachines/play-tanstack-react-router";import { createRouteMap } from "@xmachines/play-router/xstate";import type { AnyStateMachine } from "xstate";
declare const machine: AnyStateMachine; // the routable machine from the example above
const router = createRouter({ routeTree: createRootRoute() });// The bridge needs the routing capabilityconst actor = definePlayer({ machine, actor: compose(PlayerActor, withRouting) })();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 atoms 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 atoms 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.
import type { ReactNode } from "react";import type { RoutableActor, RouteMap, TanStackRouterInstance,} from "@xmachines/play-tanstack-react-router";
interface PlayRouterProviderProps<TActor extends RoutableActor> { actor: TActor; // Must be stable router: TanStackRouterInstance; // Must be stable routeMap: RouteMap; // Must be stable basePath?: string; // Optional URL prefix that the host owns basePathParams?: Record<string, string | number>; // The values of the `:param` segments of basePath 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; search?: Record<string, unknown> }): void; options?: { parseSearch?(search: string): Record<string, unknown> }; load?(): void | Promise<void>; history: { location: { pathname: string; search?: string }; subscribe( handler: (event: { location: { pathname: string; search?: string } }) => void, ): () => void; };};Re-exported from @xmachines/play-router
import { // Route map construction RouteMap, createRouteMapFromTree, // (routeTree) => RouteMap // Types type RouteMapOptions, type RouteMapping, type RouterBridge, type PlayRouteEvent,} from "@xmachines/play-tanstack-react-router";
// `createRouteMap` and `extractMachineRoutes` read a state machine. This barrel names// none, so import them from `@xmachines/play-router/xstate`.How It Works
The bridge implements the Passive Infrastructure invariant from the XMachines RFC:
- Actor → Router: when the
actor.currentRouteatom 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). - Corrective navigation under a mounted
<RouterProvider>: for every history event, the bridge callsrouter.load()and waits for the promise before it synchronizes the actor. A mounted<RouterProvider>defers the refresh ofrouter.latestLocation, and without the wait TanStack drops the corrective navigation for a refused URL. See the@xmachines/play-tanstack-routerREADME for the details.
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.