Skip to content

@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.currentRoute signal
  • 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

Terminal window
npm install vue-router@^4.0.0 vue@^3.5.0 @xmachines/play-vue-router @xmachines/play-vue

Peer dependencies:

  • vue-router ^4.0.0 || ^5.0.0 — Vue Router library
  • vue ^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 routes
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],
})),
});
// 3. Compute route mapping from machine routes
const routeMap = createRouteMap(authMachine);
// 4. Create actor with state machine
const createPlayer = definePlayer({
machine: authMachine,
catalog: componentCatalog,
});
const actor = createPlayer();
actor.start();
// 5. Create bridge to sync actor and router
const bridge = new VueRouterBridge(router, actor, routeMap);
// 6. Connect bridge (required)
bridge.connect();
// 7. Create Vue app
const 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 from createRouter()
  • actor - XMachines actor instance (from definePlayer().actor)
  • routeMap - Bidirectional state ID ↔ route name mapping

Methods:

  • connect() - Start bidirectional synchronization.
  • disconnect() - Stop synchronization and unhook listeners.
  • dispose() - Alias of disconnect() for ergonomic teardown.

Internal Behavior:

  • Watches actor.currentRoute signal via Signal.subtle.Watcher
  • Updates Vue Router via router.push({ name, params }) when actor state changes
  • Listens to router navigation via router.afterEach() hook
  • Sends play.route events 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 ID
  • getStateId(routeName) — Find state ID from route name
  • getPattern(stateId) — Get URL pattern for state (optional metadata)
  • getStateIdByPath(path) — Resolve a URL path to a state ID (from BaseRouteMap)
  • getPathByStateId(stateId) — Get the URL path pattern for a state ID (from BaseRouteMap)

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 states
import { 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 configuration
const 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 routes
const routeMap = createRouteMap(appMachine);

Parameter Handling: Dynamic Routes with :param Syntax

// State machine with parameter routes
import { 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 routes
const 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 routes
const 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 handling
import { 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 params
function 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 guards
import { 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.route event to actor
  • Actor’s always guard checks isAuthenticated
  • If false, actor transitions to login state
  • 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 guards
onUnmounted(() => {
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):

  1. Actor transitions to new state with meta.route
  2. actor.currentRoute signal updates
  3. Signal.subtle.Watcher detects change in microtask
  4. Bridge extracts state ID from signal
  5. Bridge looks up route name via routeMap.getRouteName()
  6. Bridge calls router.push({ name, params })
  7. Vue Router updates URL and renders component

Router → Actor (Navigation guard):

  1. User clicks link or browser back button
  2. router.afterEach() hook fires with to route
  3. Bridge resolves state ID from Vue route name via routeMap.getStateId(...)
  4. Bridge extracts params from to.params (not route.params - see Pitfalls)
  5. Bridge sends play.route event to actor
  6. Actor validates navigation (guards, transitions)
  7. If accepted: Actor transitions, signal updates, URL stays
  8. If rejected: Actor redirects, bridge corrects URL via router.replace()

Circular Update Prevention

Multi-layer guards prevent infinite loops:

  1. lastSyncedPath tracking: Stores last synchronized path, skips if unchanged
  2. isProcessingNavigation flag: Set during router-initiated navigation, prevents actor→router sync
  3. 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 via definePlayer()

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 transition
router.afterEach((to) => {
const route = useRoute(); // Returns "from" route
const params = route.params; // STALE
// ...
});
// ✅ CORRECT: to.params is fresh
router.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.

Classes

Interfaces

Type Aliases

Variables

Functions