Skip to content

@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.

License: MIT Version

Installation

Terminal window
pnpm add @xmachines/play-tanstack-react-router

Peer dependencies. Install them separately:

Terminal window
pnpm add @tanstack/react-router react react-dom xstate

The adapter requires:

  • @tanstack/react-router ^1.168.8
  • react ^18.0.0 or ^19.0.0
  • react-dom ^18.0.0 or ^19.0.0
  • xstate ^5.31.0

Usage

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, and routeMap must stay stable across the renders. If one prop gets a new identity, the bridge disconnects, then it connects again. Use useMemo to 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 PlayRouteEvent

How It Works

The bridge implements the Passive Infrastructure invariant from the XMachines RFC:

  1. Actor → Router: when the actor.currentRoute signal changes, the bridge calls router.navigate({ to: path }). The URL then shows the new actor state.
  2. Router → Actor: when router.history.subscribe fires, the bridge sends a play.route event to the actor. A link click, a BACK or FORWARD button, and a call to history.pushState each cause this. The guards of the actor decide if the navigation is valid. The router never enforces the business logic.
  3. Circular update prevention: the lastSyncedPath guard stops a return update. An actor-to-router navigation therefore does not cause an unnecessary router-to-actor send.
  4. Deep-link and restore: on connect(), the bridge reads router.history.location.pathname. That value shows window.location at once, before router.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:

Terminal window
pnpm --filter @xmachines/play-tanstack-react-router test

From the monorepo root:

Terminal window
pnpm test

Tests 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:

Terminal window
pnpm install
pnpm --filter @xmachines/play-tanstack-react-router-demo run dev

Then 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

Type Aliases

Variables

Functions