Skip to content

@xmachines/play-react-router-demo

Examples / @xmachines/play-react-router-demo

React Router v7 integration demo for the XMachines Play architecture.

What This Demonstrates

  • Shared auth machine reused without framework-specific business logic
  • PlayRouterProvider renderer-based integration with React Router
  • Shell-driven rendering via PlayRenderer with actor-authoritative navigation
  • Canonical TC39 Signals lifecycle mapped to React’s rendering loop
  • Browser E2E coverage via a shared demo browser suite

Running the Demo

From the repository root:

Terminal window
npm install
npm run dev -w @xmachines/play-react-router-demo

Then open http://localhost:5173.

Step-by-Step Code Flow

Use this order to understand the implementation:

  1. src/main.tsx mounts <App />.
  2. src/App.tsx calls useMemo(createAppRuntime, []) to create the actor and browser router, starting the actor once per mounted app.
  3. src/runtime.ts pre-builds the routeMap (via createRouteMap(authMachine)) and exports createPlayer, routeMap, and createRegistryResult — keeping runtime setup separate from React lifecycle concerns.
  4. createAppRuntime() creates the actor, builds a registryResult from the registry, and constructs a React Router data router with a RoutedShell wrapper.
  5. PlayRouterProvider wires actor navigation and router changes in both directions, using the pre-built routeMap.
  6. Shell (inside App.tsx) receives actor, router, and registryResult, matching the other router demos.
  7. The renderer prop renders <RouterProvider router={currentRouter} /> directly — no intermediate ReactRouterApp wrapper is needed.
  8. Browser tests in test/browser/ validate startup and auth navigation flow via a shared demo suite.
// src/main.tsx (shape)
createRoot(document.getElementById("root")!).render(<App />);
// src/runtime.ts (shape)
export const createPlayer = definePlayer({ machine: authMachine });
export const routeMap = createRouteMap(authMachine);
export function createRegistryResult(actor: ReturnType<typeof createPlayer>) {
return defineRegistry(authCatalog, {
components: { Home, About, Contact, Login, Dashboard /* … */ },
actions: {
login: async (args) => actor.send({ type: "auth.login", username: args.username }),
logout: async () => actor.send({ type: "auth.logout" }),
route: async (args) => actor.send({ type: "play.route", to: args.to }),
},
});
}
// src/App.tsx (shape)
function createAppRuntime() {
const actor = createPlayer();
actor.start();
const registryResult = createRegistryResult(actor);
let router!: BrowserRouterInstance;
function RoutedShell() {
return <Shell actor={actor} router={router} registryResult={registryResult} />;
}
router = createBrowserRouter([{ path: "*", element: <RoutedShell /> }]);
return { actor, router };
}
const { actor, router } = useMemo(createAppRuntime, []);
return (
<PlayRouterProvider
actor={actor}
router={router}
routeMap={routeMap}
renderer={(_, currentRouter) => <RouterProvider router={currentRouter} />}
/>
);

Key Files

  • src/main.tsx - React entry point that mounts <App />
  • src/runtime.ts - actor factory (createPlayer), pre-built routeMap, and createRegistryResult registry wiring
  • src/App.tsx - actor lifecycle, provider wiring, and RoutedShell composition
  • test/browser/shared-demo.browser.test.tsx - browser E2E suite for startup and auth navigation flow

State Machine & Architecture Details

The demo utilizes XMachines architectural invariants:

  1. Actor Authority: Navigation triggers URL changes, which the PlayRouterProvider intercepts and converts into play.route events. The actor evaluates these events against its internal guards and transitions.
  2. Passive Infrastructure: The router does not execute business logic. The actor dictates whether navigation is permitted. The React application only triggers renders.
  3. Signal-Only Reactivity: The bridge leverages React’s useSyncExternalStore (internally within the hooks) to react precisely when signals update, without polluting the React component tree with useState or useEffect for business logic.

Watcher Lifecycle and Cleanup Contract

This demo follows the same watcher lifecycle used across @xmachines/play-signals and framework adapters:

  1. notify
  2. queueMicrotask
  3. Drain pending work with getPending()
  4. Read actor signals and project framework-local state
  5. Re-arm via watch()/watch(...signals)

Watcher notifications are one-shot. Cleanup is explicit: bridge/provider teardown must call disconnect/unwatch paths, never rely on GC-only cleanup. The PlayRouterProvider handles this natively on component unmount.

Adapter Boundaries

PlayRouterProvider and the React Router bridge stay passive infrastructure. Business validity remains actor-owned, while RouterBridgeBase remains the shared policy layer and the concrete React adapter stays a thin port, delegating DOM synchronization to React Router. The demo keeps the same Shell(actor, router, registryResult) shape as the other router demos, with RouterProvider rendered directly inside the renderer callback — no additional wrapper component is required.

Available Scripts

These commands are defined in package.json:

CommandDescription
npm run dev -w @xmachines/play-react-router-demoStart Vite dev server
npm run build -w @xmachines/play-react-router-demoBuild production bundle
npm run preview -w @xmachines/play-react-router-demoPreview built bundle
npm run test -w @xmachines/play-react-router-demoRun Vitest test suite
npm run test:browser -w @xmachines/play-react-router-demoRun browser-focused Vitest suite

Verification

Use these checks to validate README claims against the current demo implementation:

Terminal window
npm run test -w @xmachines/play-react-router-demo
npm run test:browser -w @xmachines/play-react-router-demo

Expected result: tests pass for startup and auth-flow browser scenarios.

Learn More