Skip to content

@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

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

Current Exports

  • PlayRenderer (Vue component)
  • PlayRendererProps (TypeScript interface)

Peer dependencies:

  • vue ^3.5.0 — Vue 3 runtime

Quick Start

App.vue
<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 with currentView signal
  • components - Map of component names to Vue components

Slots:

  • fallback - Slot shown when currentView is null

Behavior:

  1. Subscribes to actor.currentView signal using a Signal.subtle.Watcher inside a Vue component
  2. Looks up component from components map using view.component string
  3. Renders component with props from view.props + send function 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

App.vue
<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 Header
provide("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>
Header.vue
<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):

  1. 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
  2. Signals as Source of Truth:

    • actor.currentView.get() provides UI structure
    • actor.currentRoute.get() provides navigation state
    • Components observe signals via explicit watcher patterns
  3. Event Forwarding:

    • Components receive send function via props
    • User actions send events to actor (e.g., { type: "auth.login" })
    • Actor guards validate and process events
  4. Microtask Batching:

    • Signal.subtle.Watcher coalesces rapid signal changes
    • Prevents Vue thrashing from multiple signal updates
    • Single Vue render per microtask batch
  5. Explicit Disposal Contract:

    • Component teardown calls watcher unwatch in onUnmounted
    • Do not rely on GC-only cleanup

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:

  1. notify callback runs
  2. Schedule work with queueMicrotask
  3. Drain watcher.getPending()
  4. Read actor signals and update Vue-local ref state
  5. Re-arm with watch(...) or watch()

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

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

Interfaces

Variables