@xmachines/play-vue
Documentation / @xmachines/play-vue
Vue renderer consuming signals and UI schema with provider pattern
Signal-driven Vue rendering layer observing actor state with zero Vue state for business logic.
Overview
@xmachines/play-vue provides PlayRenderer for building Vue 3 UIs that passively observe actor signals. This package enables framework-swappable architecture where Vue is just a rendering target subscribing to signal changes — business logic lives entirely in the actor.
Per RFC Play v1, this package implements:
- Signal-Only Reactivity (INV-05): No refs/reactive for business logic, TC39 signals only
- Passive Infrastructure (INV-04): Components observe signals, send events to actor
Key Principle: Vue state is never used for business logic. Signals are the source of truth.
Renderer receives actor via props (provider pattern), not children.
Installation
npm install vue@^3.5.0npm install @xmachines/play-vueCurrent Exports
PlayRenderer(Vue component)PlayRendererProps(TypeScript interface)
Peer dependencies:
vue^3.5.0 — Vue 3 runtime
Quick Start
<script setup lang="ts">import { definePlayer } from "@xmachines/play-xstate";import { defineCatalog } from "@xmachines/play-catalog";import { PlayRenderer } from "@xmachines/play-vue";import { z } from "zod";import LoginForm from "./components/LoginForm.vue";import Dashboard from "./components/Dashboard.vue";
// 1. Define catalog (business logic layer)const catalog = defineCatalog({ LoginForm: z.object({ error: z.string().optional() }), Dashboard: z.object({ userId: z.string(), username: z.string(), }),});
// 2. Define component map (view layer)const components = { LoginForm, Dashboard,};
// 3. Create player actor (business logic runtime)const createPlayer = definePlayer({ machine: authMachine, catalog });const actor = createPlayer();actor.start();</script>
<template> <!-- 4. Render UI (actor via props) --> <PlayRenderer :actor="actor" :components="components"> <template #fallback> <div>Loading...</div> </template> </PlayRenderer></template>API Reference
PlayRenderer
Main renderer component subscribing to actor signals and dynamically rendering catalog components:
interface PlayRendererProps { actor: AbstractActor<any>; components: Record<string, Component>;}Props:
actor- Actor instance withcurrentViewsignalcomponents- Map of component names to Vue components
Slots:
fallback- Slot shown whencurrentViewis null
Behavior:
- Subscribes to
actor.currentViewsignal using aSignal.subtle.Watcherinside a Vue component - Looks up component from
componentsmap usingview.componentstring - Renders component with props from
view.props+sendfunction via Vue’s dynamic<component :is="..."/>
Example Component (LoginForm.vue):
<script setup lang="ts">import type { AbstractActor } from "@xmachines/play-actor";import { ref } from "vue";
const props = defineProps<{ error?: string; send: AbstractActor<any>["send"];}>();
const username = ref("");
function handleSubmit() { props.send({ type: "auth.login", username: username.value, });}</script>
<template> <form @submit.prevent="handleSubmit"> <p v-if="error" style="color: red">{{ error }}</p> <input v-model="username" required placeholder="Username" /> <button type="submit">Log In</button> </form></template>Examples
Provider Pattern
<script setup lang="ts">import { PlayVueRouterProvider } from "@xmachines/play-vue-router";import { PlayRenderer } from "@xmachines/play-vue";import { provide } from "vue";import Header from "./components/Header.vue";import Footer from "./components/Footer.vue";
// Provide actor to nested components like Headerprovide("actor", actor);</script>
<template> <PlayVueRouterProvider :actor="actor" :router="router" :routeMap="routeMap"> <template #default="{ currentActor, currentRouter }"> <div> <Header /> <PlayRenderer :actor="currentActor" :components="components" /> <Footer /> </div> </template> </PlayVueRouterProvider></template><script setup lang="ts">import { inject, ref, onMounted, onUnmounted } from "vue";import type { AbstractActor } from "@xmachines/play-actor";
const actor = inject<AbstractActor<any>>("actor")!;const route = ref<string | null>(null);
let watcher: any;
onMounted(() => { let pending = false; watcher = new Signal.subtle.Watcher(() => { if (!pending) { pending = true; queueMicrotask(() => { pending = false; for (const s of watcher.getPending()) s.get(); route.value = actor.currentRoute.get(); watcher.watch(actor.currentRoute); }); } }); route.value = actor.currentRoute.get(); watcher.watch(actor.currentRoute);});
onUnmounted(() => { if (watcher) watcher.unwatch(actor.currentRoute);});</script>
<template> <header> <nav>Current: {{ route }}</nav> </header></template>Architecture
This package implements Signal-Only Reactivity (INV-05) and Passive Infrastructure (INV-04):
-
No Business Logic in Vue:
- No ref/reactive for business state
- No watch/watchEffect for business side effects
- Vue only triggers renders, doesn’t control state
-
Signals as Source of Truth:
actor.currentView.get()provides UI structureactor.currentRoute.get()provides navigation state- Components observe signals via explicit watcher patterns
-
Event Forwarding:
- Components receive
sendfunction via props - User actions send events to actor (e.g.,
{ type: "auth.login" }) - Actor guards validate and process events
- Components receive
-
Microtask Batching:
Signal.subtle.Watchercoalesces rapid signal changes- Prevents Vue thrashing from multiple signal updates
- Single Vue render per microtask batch
-
Explicit Disposal Contract:
- Component teardown calls watcher
unwatchinonUnmounted - Do not rely on GC-only cleanup
- Component teardown calls watcher
Pattern:
- Renderer receives actor via props (provider pattern)
- Enables composition with navigation, headers, footers
- Supports multiple renderers in same app
Architectural Invariants:
- Signal-Only Reactivity (INV-05): No Vue state for business logic
- Passive Infrastructure (INV-04): Components reflect, never decide
Canonical Watcher Lifecycle
If you write your own custom integration, use the same watcher flow as PlayRenderer:
notifycallback runs- Schedule work with
queueMicrotask - Drain
watcher.getPending() - Read actor signals and update Vue-local ref state
- Re-arm with
watch(...)orwatch()
Watcher notify is one-shot. Re-arm is required for continuous observation.
Benefits
- Framework Swappable: Business logic has zero Vue imports
- Type Safety: Props validated against catalog schemas
- Simple Testing: Test actors without Vue renderer
- Performance: Microtask batching reduces unnecessary renders
- Composability: Renderer prop enables complex layouts
Related Packages
- @xmachines/play-xstate - XState adapter providing actors
- @xmachines/play-catalog - UI schema validation
- @xmachines/play-vue-router - Vue Router integration
- @xmachines/play-actor - Actor base
- @xmachines/play-signals - TC39 Signals primitives
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.
@xmachines/play-vue - Vue renderer for XMachines Play architecture