Skip to content

Multi-Router Integration - Renderer Prop Pattern

Learn how to integrate Play Architecture with three router modes using the unified renderer prop pattern introduced in

Overview

introduces a unified multi-router architecture supporting three integration modes with consistent API:

  1. TanStack Router - React + TanStack Router with full feature set
  2. Vanilla Router - JSX frameworks (Preact, Solid, Vue) with framework-agnostic router
  3. Pure Browser - Manual integration for jQuery, Alpine, vanilla JS

All modes use the renderer prop pattern where providers receive an actor and router, then pass a renderer function (not children).

Key Concepts

1. Renderer Prop Pattern

All providers use a renderer prop that receives the actor and returns a ReactNode:

<PlayTanStackRouterProvider
actor={actor}
router={router}
renderer={(actor) => <PlayRenderer actor={actor} components={components} />}
/>

Why renderer prop instead of children?

  • Explicit dependency injection (actor passed to renderer function)
  • Type-safe integration (renderer function signature enforced)
  • Consistent pattern across all three modes

2. RouteMap as Explicit Prop

All providers require routeMap as an explicit prop. Routers fundamentally don’t know about Play state IDs — the map bridges the gap:

const routeTree = createPlayRouter({ machine });
const router = createRouter({ routeTree, history: createMemoryHistory() });
<PlayTanStackRouterProvider
actor={actor}
router={router}
routeMap={routeMap} // Explicit mapping
renderer={(actor) => <PlayRenderer actor={actor} components={components} />}
/>

3. Provider Composition Pattern

Providers wrap external providers (like TanStack’s RouterProvider) and add Play integration layer:

// Provider wraps TanStack RouterProvider
<RouterProvider router={router}>
{/* Play integration layer */}
{renderer(actor)}
</RouterProvider>

Mode 1: TanStack Router (React)

Complete integration with React and TanStack Router.

See multi-router-example.ts for runnable example.

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createRouter, createMemoryHistory } from '@tanstack/react-router';
import { createPlayRouter, PlayTanStackRouterProvider } from '@xmachines/play-tanstack-react-router';
import { PlayRenderer } from '@xmachines/play-react';
import { definePlayer } from '@xmachines/play-xstate';
// 1. Create actor
const createPlayer = definePlayer({ machine, catalog });
const actor = createPlayer();
// 2. Create router from machine
const routeTree = createPlayRouter({ machine });
const router = createRouter({
routeTree,
history: createMemoryHistory()
});
// 3. Define React components
const components = {
HomeView: ({ send }) => (
<div>
<h1>Home</h1>
<button onClick={() => send({ type: 'play.route', to: '/about' })}>
Go to About
</button>
</div>
),
AboutView: ({ send }) => (
<div>
<h1>About</h1>
<button onClick={() => send({ type: 'play.route', to: '/' })}>
Go to Home
</button>
</div>
)
};
// 4. Render with provider + renderer prop
function App() {
return (
<PlayTanStackRouterProvider
actor={actor}
router={router}
renderer={(actor) => <PlayRenderer actor={actor} components={components} />}
/>
);
}
const root = createRoot(document.getElementById('app')!);
root.render(<StrictMode><App /></StrictMode>);

Benefits

  • Full React ecosystem support
  • Type-safe routing with TanStack Router
  • Browser history integration automatic
  • Signal reactivity via PlayRenderer

Mode 2: Vanilla Router (Preact, Solid, Vue)

Framework-agnostic router for JSX-based frameworks.

import { createPlayRouter, connectRouter } from '@xmachines/play-router';
import { definePlayer } from '@xmachines/play-xstate';
// 1. Create actor
const createPlayer = definePlayer({ machine, catalog });
const actor = createPlayer();
// 2. Create framework-agnostic router
const router = createPlayRouter({ machine });
// 3. Connect router with manual integration
const disconnect = connectRouter(actor, {
onNavigate: (path) => {
// Update your framework's router
myFrameworkRouter.push(path);
}
});
// 4. Framework-specific rendering
// Preact:
import { h } from 'preact';
const view = actor.currentView.get();
render(h(components[view.component], viewProps), container);
// Solid:
import { Dynamic } from 'solid-js/web';
<Dynamic component={components[view.component]} {...viewProps} />
// Vue 3:
import { h, resolveComponent } from 'vue';
h(resolveComponent(view.component), viewProps)

Benefits

  • Works with any JSX framework
  • Manual control over routing integration
  • No React dependency
  • Same actor/signal API

Mode 3: Pure Browser (jQuery, Alpine, Vanilla JS)

Manual browser integration with no framework.

import { createPlayRouter, connectRouter } from "@xmachines/play-router";
// 1. Create actor
const actor = createPlayer();
// 2. Connect router with browser integration
const disconnect = connectRouter(actor, {
onNavigate: (path) => {
// Update browser history
window.history.pushState({}, "", path);
renderView();
},
});
// 3. Manual rendering
function renderView() {
const view = actor.currentView.get();
// Vanilla JS rendering
if (view.component === "HomeView") {
document.getElementById("app").innerHTML = `
<h1>Home</h1>
<button onclick="navigateToAbout()">Go to About</button>
`;
} else if (view.component === "AboutView") {
document.getElementById("app").innerHTML = `
<h1>About</h1>
<button onclick="navigateToHome()">Go to Home</button>
`;
}
}
// 4. Listen to browser back/forward
window.addEventListener("popstate", () => {
const path = window.location.pathname;
actor.send({ type: "play.route", to: path });
});
// 5. Manual event handlers
function navigateToAbout() {
actor.send({ type: "play.route", to: "/about" });
}
function navigateToHome() {
actor.send({ type: "play.route", to: "/" });
}
// Start
actor.start();
renderView();

Benefits

  • No framework dependency at all
  • Maximum control and flexibility
  • Works with jQuery, Alpine.js, HTMX, etc.
  • Signals available via actor.currentView.get()

Provider API Parallelism

All three modes follow the same pattern:

ConceptTanStack ModeVanilla ModePure Browser Mode
ActorcreatePlayer()createPlayer()createPlayer()
RoutercreateRouter() (TanStack)createPlayRouter()connectRouter()
Integration<PlayTanStackRouterProvider>Manual via onNavigate callbackManual + window.popstate
Rendering<PlayRenderer> via renderer propFramework-specific (h, Dynamic, etc)innerHTML or DOM API
EventsComponent onClicksend()SameSame
SignalsObserved by useSignalEffectManual actor.currentView.get()Same

The API parallelism ensures consistent patterns regardless of mode.

Architectural Invariants

All three modes preserve the 5 architectural invariants:

  1. Actor Authority (INV-01): Guards validate all navigation across all modes
  2. Strict Separation (INV-02): Business logic (machine) has zero framework imports
  3. Signal-Only Reactivity (INV-05): TC39 Signals work identically in all modes
  4. Passive Infrastructure (INV-04): Router observes actor, never decides
  5. State-Driven Reset (INV-03): Browser back/forward sends events to actor

Next Steps

Learn More