@xmachines/play-vue-router
Documentation / @xmachines/play-vue-router
Vue Router 4.x adapter for XMachines Universal Player Architecture
Bidirectional sync between Vue Router and XMachines state machines with Composition API integration.
Overview
@xmachines/play-vue-router enables Vue.js applications to use XMachines state machines as the source of truth for routing logic. Your state machine controls navigation through Vue Router’s reactive primitives.
Per RFC Play v1, this package implements:
- Actor Authority (INV-01): State machine validates navigation, router reflects decisions
- Passive Infrastructure (INV-04): Router observes
actor.currentRoutesignal - Signal-Only Reactivity (INV-05): Watcher synchronizes URL with actor state
Key Benefits:
- Logic-driven navigation: Business logic in state machines, not components
- Protected routes: Guards live in state machine, not router config
- Bidirectional sync: Actor ↔ Vue Router with circular update prevention
- Type-safe parameters: Route params flow through state machine context
- Composition API: Integrates with
useRouter,useRoute,onUnmounted
Framework Compatibility:
- Vue 3.x with Composition API
- Vue Router 4.x (
^4.0.0) - Named routes pattern (recommended by Vue Router docs)
Installation
npm install vue-router@^4.0.0 vue@^3.5.0 @xmachines/play-vue-router @xmachines/play-vuePeer dependencies:
vue-router^4.0.0 || ^5.0.0 — Vue Router libraryvue^3.5.0 — Vue runtime@xmachines/play-vue— Vue renderer (PlayRenderer)@xmachines/play-actor— Actor base@xmachines/play-router— Route extraction@xmachines/play-signals— TC39 Signals primitives
Quick Start
import { createApp } from "vue";import { createRouter, createWebHistory } from "vue-router";import { VueRouterBridge, createRouteMap } from "@xmachines/play-vue-router";import { extractMachineRoutes, getRoutableRoutes } from "@xmachines/play-router";import { definePlayer } from "@xmachines/play-xstate";import Home from "./views/Home.vue";import Profile from "./views/Profile.vue";
// 1. Extract routable states from machine (single source of truth)const routeTree = extractMachineRoutes(authMachine);const routableRoutes = getRoutableRoutes(routeTree);const routeComponents = { home: Home, profile: Profile,} as const;
// 2. Define Vue Router routes from extracted machine routesconst router = createRouter({ history: createWebHistory(), routes: routableRoutes.map((route) => ({ path: route.fullPath, name: route.stateId.replace(/^#/, ""), component: routeComponents[route.stateId.replace(/^#/, "") as keyof typeof routeComponents], })),});
// 3. Compute route mapping from machine routesconst routeMap = createRouteMap(authMachine);
// 4. Create actor with state machineconst createPlayer = definePlayer({ machine: authMachine, catalog: componentCatalog,});const actor = createPlayer();actor.start();
// 5. Create bridge to sync actor and routerconst bridge = new VueRouterBridge(router, actor, routeMap);
// 6. Connect bridge (required)bridge.connect();
// 7. Create Vue appconst app = createApp(App);app.use(router);app.mount("#app");
// 8. Later, when tearing down your app/integration:// bridge.disconnect();API Reference
VueRouterBridge
Router adapter implementing the RouterBridge protocol for Vue Router 4.x.
Type Signature:
class VueRouterBridge { constructor(router: Router, actor: AbstractActor<any>, routeMap: RouteMap); connect(): void; disconnect(): void; dispose(): void;}Constructor Parameters:
router- Vue Router instance fromcreateRouter()actor- XMachines actor instance (fromdefinePlayer().actor)routeMap- Bidirectional state ID ↔ route name mapping
Methods:
connect()- Start bidirectional synchronization.disconnect()- Stop synchronization and unhook listeners.dispose()- Alias ofdisconnect()for ergonomic teardown.
Internal Behavior:
- Watches
actor.currentRoutesignal viaSignal.subtle.Watcher - Updates Vue Router via
router.push({ name, params })when actor state changes - Listens to router navigation via
router.afterEach()hook - Sends
play.routeevents to actor when user navigates - Prevents circular updates with multi-layer guards
RouteMap
Bidirectional mapping between XMachines state IDs and Vue Router route names.
Type Signature:
interface RouteMapping { stateId: string; routeName: string; pattern?: string;}
class RouteMap extends VueBaseRouteMap { // Inherits all VueBaseRouteMap methods — no additional API}Constructor Parameters:
mappings- Array of mapping objects with:stateId- State machine state ID (e.g.,'#profile')routeName- Vue Router route name (e.g.,'profile')pattern- Optional path pattern for URL resolution (e.g.,'/profile/:userId')
Methods (inherited from VueBaseRouteMap):
getRouteName(stateId)— Find route name from state IDgetStateId(routeName)— Find state ID from route namegetPattern(stateId)— Get URL pattern for state (optional metadata)getStateIdByPath(path)— Resolve a URL path to a state ID (fromBaseRouteMap)getPathByStateId(stateId)— Get the URL path pattern for a state ID (fromBaseRouteMap)
VueBaseRouteMap
Intermediate base class for Vue Router adapters. Extends BaseRouteMap (bucket-indexed O(k) pattern matching + QuickLRU cache) and adds Vue-specific named-route lookup.
Exported for consumers who need to extend or test the Vue routing layer directly. Most consumers use RouteMap instead.
class VueBaseRouteMap extends BaseRouteMap { constructor(mappings: RouteMapping[]); getRouteName(stateId: string): string | undefined; getStateId(routeName: string): string | undefined; getPattern(stateId: string): string | undefined;}Examples
Basic Usage: Simple 2-3 Route Setup
// State machine with 3 statesimport { defineCatalog } from "@xmachines/play-catalog";
const appMachine = setup({ types: { events: {} as PlayRouteEvent, },}).createMachine({ id: "app", initial: "home", states: { home: { meta: { route: "/", view: { component: "Home" } }, }, about: { meta: { route: "/about", view: { component: "About" } }, }, contact: { meta: { route: "/contact", view: { component: "Contact" } }, }, },});
const componentCatalog = defineCatalog({ Home, About, Contact,});
const player = definePlayer({ machine: appMachine, catalog: componentCatalog });
// Vue Router configurationconst routeTree = extractMachineRoutes(appMachine);const routableRoutes = getRoutableRoutes(routeTree);const routeComponents = { home: Home, about: About, contact: Contact,} as const;
const router = createRouter({ history: createWebHistory(), routes: routableRoutes.map((route) => ({ path: route.fullPath, name: route.stateId.replace(/^#/, ""), component: routeComponents[route.stateId.replace(/^#/, "") as keyof typeof routeComponents], })),});
// Route mapping computed from machine routesconst routeMap = createRouteMap(appMachine);Parameter Handling: Dynamic Routes with :param Syntax
// State machine with parameter routesimport { formatPlayRouteTransitions } from "@xmachines/play-xstate";import { defineCatalog } from "@xmachines/play-catalog";
const machineConfig = { id: "app", context: {}, states: { profile: { meta: { route: "/profile/:userId", view: { component: "Profile" }, }, }, settings: { meta: { route: "/settings/:section?", view: { component: "Settings" }, }, }, },};
const appMachine = setup({ types: { context: {} as { userId?: string; section?: string }, events: {} as PlayRouteEvent, },}).createMachine(formatPlayRouteTransitions(machineConfig));
const componentCatalog = defineCatalog({ Profile, Settings,});
const player = definePlayer({ machine: appMachine, catalog: componentCatalog });
// Router with dynamic routesconst routeTree = extractMachineRoutes(appMachine);const routableRoutes = getRoutableRoutes(routeTree);const routeComponents = { profile: Profile, settings: Settings,} as const;
const router = createRouter({ routes: routableRoutes.map((route) => ({ path: route.fullPath, name: route.stateId.replace(/^#/, ""), component: routeComponents[route.stateId.replace(/^#/, "") as keyof typeof routeComponents], })),});
// Route mapping computed from machine routesconst routeMap = createRouteMap(appMachine);Usage in component:
<script setup>import { inject } from "vue";
const actor = inject("actor");
function viewProfile(userId) { actor.send({ type: "play.route", to: "#profile", params: { userId } });}</script>
<template> <button @click="viewProfile('123')">View Profile</button></template>Query Parameters: Search/Filters via Query Strings
// State machine with query param handlingimport { formatPlayRouteTransitions } from "@xmachines/play-xstate";import { defineCatalog } from "@xmachines/play-catalog";
const machineConfig = { context: { query: "", filters: {} }, states: { search: { meta: { route: "/search", view: { component: "Search" }, }, }, },};
const searchMachine = setup({ types: { context: {} as { query: string; filters: Record<string, string> }, events: {} as PlayRouteEvent, },}).createMachine(formatPlayRouteTransitions(machineConfig));
const componentCatalog = defineCatalog({ Search,});
const player = definePlayer({ machine: searchMachine, catalog: componentCatalog });
// Component sends query paramsfunction handleSearch(searchTerm, filters) { actor.send({ type: "play.route", to: "#search", query: { q: searchTerm, ...filters }, });}Vue Router automatically reflects query params in URL:
/search?q=xmachines&tag=typescript
Protected Routes: Authentication Guards
// State machine with auth guardsimport { defineCatalog } from "@xmachines/play-catalog";
const authMachine = setup({ types: { context: {} as { isAuthenticated: boolean }, events: {} as PlayRouteEvent | { type: "login" } | { type: "logout" }, },}).createMachine({ context: { isAuthenticated: false }, initial: "home", states: { home: { meta: { route: "/", view: { component: "Home" } }, }, login: { meta: { route: "/login", view: { component: "Login" } }, on: { login: { target: "dashboard", actions: assign({ isAuthenticated: true }), }, }, }, dashboard: { meta: { route: "/dashboard", view: { component: "Dashboard" } }, always: { guard: ({ context }) => !context.isAuthenticated, target: "login", }, }, },});
const componentCatalog = defineCatalog({ Home, Login, Dashboard,});
const player = definePlayer({ machine: authMachine, catalog: componentCatalog });Guard behavior:
- User navigates to
/dashboard - Bridge sends
play.routeevent to actor - Actor’s
alwaysguard checksisAuthenticated - If
false, actor transitions tologinstate - Bridge detects state change, redirects router to
/login - Actor Authority principle enforced
Cleanup: Proper Disposal on Component Unmount
<script setup>import { onUnmounted } from "vue";import { VueRouterBridge } from "@xmachines/play-vue-router";
const router = useRouter();const actor = inject("actor");const routeMap = inject("routeMap");
const bridge = new VueRouterBridge(router, actor, routeMap);
// CRITICAL: Cleanup watchers and guardsonUnmounted(() => { bridge.dispose();});</script>Why cleanup matters:
- Navigation guards remain active after unmount (memory leak)
- Watchers continue observing signals (event listeners pile up)
- Multiple bridge instances send duplicate events
- Tests fail with “Cannot send to stopped actor” errors
Architecture
Bidirectional Sync (Actor ↔ Router)
Actor → Router (Signal-driven):
- Actor transitions to new state with
meta.route actor.currentRoutesignal updatesSignal.subtle.Watcherdetects change in microtask- Bridge extracts state ID from signal
- Bridge looks up route name via
routeMap.getRouteName() - Bridge calls
router.push({ name, params }) - Vue Router updates URL and renders component
Router → Actor (Navigation guard):
- User clicks link or browser back button
router.afterEach()hook fires withtoroute- Bridge resolves state ID from Vue route
nameviarouteMap.getStateId(...) - Bridge extracts params from
to.params(notroute.params- see Pitfalls) - Bridge sends
play.routeevent to actor - Actor validates navigation (guards, transitions)
- If accepted: Actor transitions, signal updates, URL stays
- If rejected: Actor redirects, bridge corrects URL via
router.replace()
Circular Update Prevention
Multi-layer guards prevent infinite loops:
lastSyncedPathtracking: Stores last synchronized path, skips if unchangedisProcessingNavigationflag: Set during router-initiated navigation, prevents actor→router sync- Microtask timing: Actor validation happens asynchronously, bridge checks result after transition completes
Pattern proven in the TanStack Router adapter:
private syncRouterFromActor(): void { if (this.isProcessingNavigation) return; // Guard 1 const currentRoute = this.actor.currentRoute.get(); if (currentRoute === this.lastSyncedPath) return; // Guard 2 this.lastSyncedPath = currentRoute; this.router.push(currentRoute);}
private syncActorFromRouter(to: RouteLocation): void { this.isProcessingNavigation = true; // Guard 3 this.actor.send({ type: 'play.route', to: stateId, params }); queueMicrotask(() => { this.isProcessingNavigation = false; // Guard 4 });}Relationship to Other Packages
Package Dependencies:
@xmachines/play- Protocol interfaces (PlayRouteEvent,RouterBridge)@xmachines/play-actor- Actor base class with signal protocol@xmachines/play-router- Route extraction and tree building@xmachines/play-signals- TC39 Signals polyfill for reactivity@xmachines/play-xstate- XState integration viadefinePlayer()
Architecture Layers:
┌─────────────────────────────────────┐│ Vue Components (View Layer) ││ - Uses inject('actor') ││ - Sends play.route events │└─────────────────────────────────────┘ ↕┌─────────────────────────────────────┐│ VueRouterBridge (Adapter) ││ - Watches actor.currentRoute ││ - Listens to router.afterEach │└─────────────────────────────────────┘ ↕ ↕┌─────────────┐ ┌──────────────────┐│ Vue Router │ │ XMachines Actor ││ (Infra) │ │ (Business Logic) │└─────────────┘ └──────────────────┘Vue Router Composition API Integration
Recommended patterns:
useRouter()- Get router instance for programmatic navigation (avoid in components - use actor)useRoute()- Access current route params (prefer actor context for state-driven components)onUnmounted()- Cleanup bridge to prevent leaks
Named routes requirement:
Vue Router adapter uses named routes (router.push({ name: 'profile', params })) instead of path-based navigation. This provides:
- Type safety (TypeScript route names)
- Cleaner param handling (object-based)
- Better Vue Router integration (recommended by docs)
All routes in your Vue Router config must have a name property for the bridge to work.
Pitfall: to.params vs route.params
⚠️ Common mistake: Using global useRoute() in navigation guards
// ❌ WRONG: route.params is stale during transitionrouter.afterEach((to) => { const route = useRoute(); // Returns "from" route const params = route.params; // STALE // ...});
// ✅ CORRECT: to.params is freshrouter.afterEach((to) => { const params = to.params; // FRESH // ...});Why it happens: Vue Router’s global route object updates asynchronously during navigation. The to parameter in afterEach is the destination route with correct params.
License
Copyright (c) 2016 Mikael Karon. All rights reserved.
This work is licensed under the terms of the MIT license.
For a copy, see https://opensource.org/licenses/MIT.