Skip to content

Vanilla JavaScript Router Demo

Examples / @xmachines/play-router-demo

Pure JavaScript integration proving Play Architecture works without any framework.

What This Demonstrates

This demo uses no framework - just vanilla JavaScript with the Browser History API and DOM manipulation. It proves that Play Architecture’s universal abstractions work at the lowest level, making framework integrations simpler by comparison.

Key Features

  • Zero Framework Dependencies: Pure JavaScript and DOM
  • Browser History API: Direct integration with window.history
  • Same Auth Machine: Identical authentication logic as React/Vue/SolidJS demos
  • Reference Implementation: This demo serves as the visual reference for all framework demos

Running the Demo

From the repository root:

Terminal window
npm install
npm run dev -w packages/play-router/examples/demo

Visit http://localhost:5174.

Step-by-Step Code Flow

Use this order to understand the demo implementation:

  1. src/main.ts creates the actor from the shared machine/catalog and starts it.
  2. src/router.ts extracts machine routes, builds browser-history routing primitives, and connects actor <-> router sync.
  3. src/shell.ts subscribes to actor signals and renders state-projected DOM views.
  4. Navigation and auth interactions dispatch actor events (play.route, auth.login, auth.logout) rather than mutating URL/state directly.
  5. Browser tests in test/browser/ validate startup rendering and the auth route flow end-to-end.
// src/main.ts (shape)
const actor = createActorFromSharedMachine();
actor.start();
const cleanupRouter = initRouter(actor);
const app = document.getElementById("app");
if (app) {
initShell(actor, app);
}
// src/router.ts (shape)
const routeTree = extractMachineRoutes(machine);
const routeMap = createRouteMap(routeTree);
const history = createBrowserHistory();
const router = createRouter({ history, routeMap });
connectRouter({ actor, router, routeMap });
// src/shell.ts (shape)
actor.currentView.watch(() => {
renderView(actor.currentView.get());
});
loginButton.onclick = () => actor.send({ type: "auth.login", username, password });
logoutButton.onclick = () => actor.send({ type: "auth.logout" });

Key Files

  • src/main.ts - actor startup, router initialization, and shell bootstrap
  • src/router.ts - createBrowserHistory, createRouter, and connectRouter wiring
  • src/shell.ts - DOM rendering and reactive watcher loop for state-driven updates
  • test/browser/startup.browser.test.ts - startup rendering assertion for public home + login action
  • test/browser/auth-flow.browser.test.ts - login -> dashboard -> profile -> logout browser flow

State Machine & Architecture Details

The demo utilizes XMachines architectural invariants at the lowest level:

  1. Actor Authority: Intercepting browser popstate events or link clicks dispatches a play.route event to the actor. The actor processes the request against the current state, ensuring any auth logic is correctly applied.
  2. Passive Infrastructure: The router simply updates window.history and the DOM solely renders HTML text based on what actor.currentView.get() yields.
  3. Signal-Only Reactivity: The integration registers manual Signal.subtle.Watcher observers that manually trigger DOM reconciliation, illustrating the underlying mechanism abstraction used by framework adapters.

Watcher Lifecycle and Cleanup Contract

Even in the vanilla demo, watcher handling follows the same canonical lifecycle:

  1. notify
  2. queueMicrotask
  3. getPending()
  4. Read actor signals and project DOM state
  5. Re-arm with watch()/watch(...signals)

Watcher notifications are one-shot, so re-arm is mandatory. Router/shell teardown must explicitly call cleanup (disconnect/unwatch) and not rely on GC-only behavior. In vanilla JS, this means retaining the cleanup functions and calling them during hot-module replacement or app unmount.

Adapter Boundaries

connectRouter and browser-history wiring are passive infrastructure. Actor logic remains authoritative for route validity, and shared synchronization policy stays in RouterBridgeBase for framework bridges.

Why Vanilla?

Vanilla JavaScript is the foundation:

  • Universal Patterns: If it works in vanilla, frameworks are just convenience layers
  • No Magic: Every interaction is explicit - great for learning
  • Performance Baseline: Shows the minimal overhead of Play Architecture

This demo proves frameworks aren’t required for Play Architecture to work.

Available Scripts

These commands are defined in package.json:

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

Verification

Validate the operational behavior with package-local tests:

Terminal window
npm run test -w packages/play-router/examples/demo
npm run test:browser -w packages/play-router/examples/demo

Expected result: tests pass, including startup rendering and the full browser auth flow in test/browser/.

Learn More