@xmachines/play-tanstack-react-router
v2.1.0
Published
TanStack React Router adapter for XMachines Play - synchronizes browser URL with actor state through passive infrastructure
Maintainers
Readme
@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 done
bridge.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 construction
RouteMap
createRouteMap(machine, options?): RouteMap
createRouteMapFromTree(routeTree): RouteMap
extractMachineRoutes(machine): RouteTree
// Types
type RouteMapOptions
type RouteMapping
type RouterBridge
type 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 install
pnpm --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.
