@xmachines/json-render-vue
| Install | |
|---|---|
npm install @xmachines/json-render-vue |
|
| Latest Version: | 0.21.0-xm.6 |
| Vue: | 3 |
| License: | Apache-2.0 |
| Last Updated: | Oct 3, 2026 |
| Links: | GitLab · npm |
@xmachines/json-render-vue
Vue 3 renderer for json-render. Turn JSON specs into Vue components with data binding, visibility, and actions.
Installation
npm install @xmachines/json-render-vue @xmachines/json-render-core zod
Peer dependencies: vue ^3.5.11 and zod ^4.0.0.
Quick Start
1. Create a Catalog
import { defineCatalog } from "@xmachines/json-render-core";
import { schema } from "@xmachines/json-render-vue/schema";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
}),
slots: ["default", "header", "footer"],
description: "A card container",
},
Button: {
props: z.object({
label: z.string(),
action: z.string(),
}),
description: "A clickable button",
},
Input: {
props: z.object({
value: z.union([z.string(), z.record(z.unknown())]).nullable(),
label: z.string(),
placeholder: z.string().nullable(),
}),
description: "Text input field with optional value binding",
},
},
actions: {
submit: { description: "Submit the form" },
cancel: { description: "Cancel and close" },
},
});
2. Define Component Implementations
Components are written using Vue's h() render function. The slots context uses Vue's native slot functions, so render a region with slots.header?.(). For convenience and React parity, children contains the already-rendered result of slots.default?.().
defineRegistry conditionally requires the actions field only when the catalog declares actions. Catalogs with actions: {} can omit it entirely.
import { h } from "vue";
import { defineRegistry } from "@xmachines/json-render-vue";
import { catalog } from "./catalog";
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, slots }) =>
h("div", { class: "card" }, [
h("header", null, slots.header?.() ?? h("h3", null, props.title)),
props.description ? h("p", null, props.description) : null,
slots.default?.(),
h("footer", null, slots.footer?.()),
]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
Input: ({ props, bindings }) => {
// Use bindings?.value with a watcher to implement two-way binding
return h("label", null, [
props.label,
h("input", {
placeholder: props.placeholder ?? "",
value: props.value ?? "",
}),
]);
},
},
// actions stubs are required when the catalog declares actions:
actions: {
submit: async () => {},
cancel: async () => {},
},
});
Tip: Use
useBoundProp(props.value, bindings?.value)for two-way binding, or handle thebindingsobject directly in your component.
Bound paths in a stateful component
A registry function runs on each render, so it reads bindings of now each
time. The setup of a stateful component runs one time, and a value that it
reads there is not reactive. Pass the bound path to a hook as a getter there.
The hook then follows the binding of now: a row of a keyed repeat that
moves, and a prop that a later spec binds or makes a literal. A plain string
stays the path of the mount. A row of a keyed repeat keeps its component
when its item moves, so a hook that got a plain string writes to the item of
the mount after a move.
const Field = defineComponent({
props: ["element", "bindings", "emit", "on", "loading"],
setup(props) {
const [, setValue] = useBoundProp<string>(
undefined,
() => props.bindings?.value,
);
const { errors } = useFieldValidation(() => props.bindings?.value, {
checks: [{ type: "required", message: "Required" }],
});
return () => [
h("input", {
value: props.element.props.value ?? "",
onInput: (e: Event) => setValue((e.target as HTMLInputElement).value),
}),
errors.value[0] ? h("p", errors.value[0]) : null,
];
},
});
useBoundProp, useFieldValidation, useStateValue and useStateBinding
take a plain path, a ref or a getter. A plain path stays as it is. A ref or
a getter gives its path of now. A component in a keyed row, and a component
that destructures bindings in its setup, passes a getter, such as
() => props.bindings?.value, to each hook.
3. Render Specs
<script setup lang="ts">
import { StateProvider, ActionProvider, Renderer } from "@xmachines/json-render-vue";
import { registry } from "./registry";
const spec = { /* ... */ };
function handleSubmit(params) {
console.log("Submit", params);
}
</script>
<template>
<StateProvider :initial-state="{ form: { name: '' } }">
<ActionProvider :handlers="{ submit: handleSubmit }">
<Renderer :spec="spec" :registry="registry" />
</ActionProvider>
</StateProvider>
</template>
Spec Format
The Vue renderer uses the same flat element map format as the React renderer:
interface Spec {
root: string; // Key of the root element
elements: Record<string, UIElement>; // Flat map of elements by key
state?: Record<string, unknown>; // Optional initial state
}
interface UIElement {
type: string; // Component name from catalog
props: Record<string, unknown>; // Component props
children?: string[]; // Keys of child elements
slots?: Record<string, string[]>; // Named slots mapped to child keys
visible?: VisibilityCondition; // Visibility condition
}
Example spec:
{
"root": "card-1",
"elements": {
"card-1": {
"type": "Card",
"props": { "title": "Welcome" },
"children": ["input-1", "btn-1"]
},
"input-1": {
"type": "Input",
"props": {
"value": { "$bindState": "/form/name" },
"label": "Name",
"placeholder": "Enter name"
}
},
"btn-1": {
"type": "Button",
"props": { "label": "Submit" },
"children": []
}
}
}
Named Slots
Use children for the default slot and the element's top-level slots object for other slot names declared by the catalog:
Layout: ({ children, slots }) =>
h("div", null, [
h("header", null, slots.header?.()),
h("main", null, children),
h("footer", null, slots.footer?.()),
]),
The corresponding spec maps element keys to each region:
{
"type": "Layout",
"props": {},
"children": ["main-content"],
"slots": {
"header": ["page-heading"],
"footer": ["page-actions"]
}
}
Registry components receive Vue-native slot functions and render them with slots.header?.(), slots.footer?.(), and so on. slots.default?.() renders the spec's children; children is an alias for that rendered result. In the JSON spec, keep default content in children rather than adding a default entry to slots.
Providers
StateProvider
Share data across components with JSON Pointer paths:
<StateProvider :initial-state="{ user: { name: 'John' } }">
<!-- children -->
</StateProvider>
// In composables:
const { state, get, set } = useStateStore();
const name = get("/user/name"); // "John"
set("/user/age", 25);
// state is a ShallowRef<StateModel> — access with state.value
console.log(state.value);
External Store (Controlled Mode)
Pass a StateStore to bypass the internal state and wire json-render to any state management library (Pinia, VueUse, etc.):
import { createStateStore, type StateStore } from "@xmachines/json-render-vue";
// Option 1: Use the built-in store outside of Vue
const store = createStateStore({ count: 0 });
<StateProvider :store="store">
<!-- children -->
</StateProvider>
// Mutate from anywhere — Vue will re-render automatically:
store.set("/count", 1);
// Option 2: Implement the StateStore interface with your own backend
const piniaStore: StateStore = {
get: (path) => getByPath(myStore.$state, path),
set: (path, value) => myStore.$patch(/* ... */),
update: (updates) => myStore.$patch(/* ... */),
getSnapshot: () => myStore.$state,
subscribe: (listener) => myStore.$subscribe(listener),
};
When store is provided, initialState and onStateChange are ignored.
ActionProvider
Handle actions from components:
<ActionProvider :handlers="{ submit: handleSubmit, cancel: handleCancel }">
<!-- children -->
</ActionProvider>
VisibilityProvider
Control element visibility based on data:
<VisibilityProvider>
<!-- children -->
</VisibilityProvider>
{
"type": "Alert",
"props": { "message": "Error!" },
"visible": { "$state": "/form/hasError" }
}
ValidationProvider
Add field validation:
<ValidationProvider>
<!-- children -->
</ValidationProvider>
// Use validation composable:
const { errors, validate } = useFieldValidation("/form/email", {
checks: [
{ type: "required", message: "Email required" },
{ type: "email", message: "Invalid email" },
],
});
The path and the config can also be refs or getters. A mounted field then registers again when its path or its config changes:
const { errors } = useFieldValidation(
() => props.path,
() => ({ checks: [{ type: "required", message: props.message }] }),
);
Composables
| Composable | Purpose |
|---|---|
useStateStore() |
Access state context (state as ShallowRef, get, set, update) |
useStateValue(path) |
Get single value from state (path can be a ref or a getter) |
useStateBinding(path) |
Two-way data binding (deprecated — use $bindState instead; path can be a ref or a getter) |
useIsVisible(condition) |
Check if a visibility condition is met |
useActions() |
Access action context |
useAction(binding) |
Get a single action dispatch function |
useFieldValidation(path, config) |
Field validation state |
Note:
useStateStore().statereturns aShallowRef<StateModel>— usestate.valueto access the underlying object. This differs from the React renderer wherestateis a plain object.
Visibility Conditions
// Truthiness check
{ "$state": "/user/isAdmin" }
// Auth state (use state path)
{ "$state": "/auth/isSignedIn" }
// Comparisons (flat style)
{ "$state": "/status", "eq": "active" }
{ "$state": "/count", "gt": 10 }
// Negation
{ "$state": "/maintenance", "not": true }
// Multiple conditions (implicit AND)
[
{ "$state": "/feature/enabled" },
{ "$state": "/maintenance", "not": true }
]
// Always / never
true // always visible
false // never visible
TypeScript helpers from @xmachines/json-render-core:
import { visibility } from "@xmachines/json-render-core";
visibility.when("/path") // { $state: "/path" }
visibility.unless("/path") // { $state: "/path", not: true }
visibility.eq("/path", val) // { $state: "/path", eq: val }
visibility.neq("/path", val) // { $state: "/path", neq: val }
visibility.and(cond1, cond2) // { $and: [cond1, cond2] }
visibility.always // true
visibility.never // false
Dynamic Prop Expressions
Any prop value can use data-driven expressions that resolve at render time. The renderer resolves these transparently before passing props to components.
{
"type": "Badge",
"props": {
"label": { "$state": "/user/role" },
"color": {
"$cond": { "$state": "/user/role", "eq": "admin" },
"$then": "red",
"$else": "gray"
}
}
}
For two-way binding, use { "$bindState": "/path" } on the natural value prop. Inside repeat scopes, use { "$bindItem": "field" } instead. Components receive resolved bindings with the state path for each bound prop.
See @xmachines/json-render-core for full expression syntax.
Built-in Actions
The setState, pushState, removeState, push, pop, and validateForm actions are built into the Vue schema and handled automatically by ActionProvider. They are injected into AI prompts without needing to be declared in your catalog's actions. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions:
{
"type": "Button",
"props": { "label": "Switch Tab" },
"on": {
"press": {
"action": "setState",
"params": { "statePath": "/activeTab", "value": "settings" }
}
},
"children": []
}
Component Props
When using defineRegistry, components receive these props via their render function:
import type { Slots, VNode } from "vue";
interface ComponentContext<P> {
props: P; // Typed props from the catalog (expressions resolved)
children?: VNode | VNode[]; // Rendered children (for container components)
slots: Slots; // Vue-native slot functions
emit: (event: string) => void; // Emit a named event (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean; // Whether the parent is loading
bindings?: Record<string, string>; // State paths for $bindState/$bindItem expressions
}
interface EventHandle {
emit: () => void; // Fire the event
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
bound: boolean; // Whether any handler is bound
}
Use emit("press") for simple event firing. Use on("click") when you need to check metadata like shouldPreventDefault or bound:
Link: ({ props, on }) => {
const click = on("click");
return h("a", {
href: props.href,
onClick: (e: MouseEvent) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
},
}, props.label);
},
BaseComponentProps
For building reusable component libraries that are not tied to a specific catalog, use the catalog-agnostic BaseComponentProps type:
import type { BaseComponentProps } from "@xmachines/json-render-vue";
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
h("div", null, [props.title, children]);
Generate AI Prompts
const systemPrompt = catalog.prompt();
// Returns detailed prompt with component/action descriptions
Full Example
import { h } from "vue";
import { defineCatalog } from "@xmachines/json-render-core";
import { schema } from "@xmachines/json-render-vue/schema";
import { defineRegistry, Renderer, StateProvider } from "@xmachines/json-render-vue";
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
Greeting: {
props: z.object({ name: z.string() }),
description: "Displays a greeting",
},
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Greeting: ({ props }) => h("h1", null, `Hello, ${props.name}!`),
},
});
const spec = {
root: "greeting-1",
elements: {
"greeting-1": {
type: "Greeting",
props: { name: "World" },
children: [],
},
},
};
// In your App.vue:
// <StateProvider>
// <Renderer :spec="spec" :registry="registry" />
// </StateProvider>
Render Errors
Renderer and the component of createRenderer take an onRenderError prop of the type OnRenderError. defineRegistry(catalog, { onRenderError }) puts the handler on the registry, and the prop takes the place of that handler. The handler gets (error, name, info). name is the element type, or the action name for a failed chain. info is a RenderErrorInfo: { kind, key, type, action }, where kind is element, props, visible, action, watch or root. A handler with two parameters still works.
- Each failure in code that the renderer runs reaches the handler one time. Without a handler, the renderer writes one
[json-render]line toconsole.error, such as[json-render] element error in <Text> "title":, and then the error. - A component that throws fails the boundary of its element, which renders nothing until a new version of the element comes. A prop or a
visiblecondition of a child that throws fails the boundary of the parent;infonames the child. - A failed action chain of an
onevent or of awatchleaves the element in place. - A failure of the root (its component, a prop or its
visiblecondition) has the kindroot, and it goes to the handler or to one console line, as any other failure. The renderer shows nothing until a new version of the root comes. The host does not get it, so a root that a stream gives only in part does not break the app. - A handler that throws gives its error to
reportErrorone time, and no other element fails. - An error of an event handler of a component, and of a listener that a component calls through
emit, is not an error of the renderer. The boundary throws it again in place, so the window gets it: anerrorevent for a listener that throws, and anunhandledrejectionevent for an async listener that rejects. A listener that a component calls in its setup, its render, a hook or a watcher fails the element. TheerrorCapturedhooks of the host andapp.config.errorHandlerdo not get these errors, in a development build and in a production build of Vue. To throw the error again in place, the boundary puts its hook first ininstance.ec, the list of the error hooks of the instance. This is the one internal of Vue that the renderer reads. Where a version of Vue has no such list, the boundary falls back: it gives each error of an event handler, and each rejection of an async listener, toreportErrorone time, the element stays, andapp.config.errorHandlerstill gets nothing. - An unknown component type with no
fallback, and arootthat names no element, give one[json-render]warning.
<Renderer
:spec="spec"
:registry="registry"
:on-render-error="(error, name, info) => report(error, info.kind, info.key)"
/>
Key Exports
| Export | Purpose |
|---|---|
defineRegistry |
Create a type-safe component registry from a catalog |
Renderer |
Render a spec using a registry |
schema |
Element tree schema (includes the built-in actions: setState, pushState, removeState, push, pop, validateForm) |
elementTreeSchema |
Deprecated alias of schema, as in @xmachines/json-render-react |
useStateStore |
Access state context (state is ShallowRef<StateModel>) |
useStateValue |
Get single value from state |
useActions |
Access actions context |
useAction |
Get a single action dispatch function |
createStateStore |
Create a framework-agnostic in-memory StateStore |
Types
| Export | Purpose |
|---|---|
ComponentContext |
Typed component render function context (catalog-aware) |
ComponentRenderProps |
Props that the renderer gives a component of a registry |
ComponentRenderer |
A Vue component that takes ComponentRenderProps |
BaseComponentProps |
Catalog-agnostic base type for reusable component libraries |
EventHandle |
Event handle with emit(), shouldPreventDefault, bound |
ActionProviderProps |
Props for ActionProvider |
ValidationProviderProps |
Props for ValidationProvider |
VisibilityProviderProps |
Props for VisibilityProvider: none, the provider renders its default slot |
ComponentFn |
Component render function type |
SetState |
State setter type |
StateModel |
State model type |
StateStore |
Interface for plugging in external state management |
OnRenderError, RenderErrorInfo, RenderErrorKind |
The onRenderError handler and its info |
Differences from @xmachines/json-render-react
Nested lists can set repeat.statePath to { "$item": "field" } to iterate an array on the enclosing repeat item.
| API | React | Vue | Note |
|---|---|---|---|
useStateStore().state |
StateModel |
ShallowRef<StateModel> |
Vue reactivity; use state.value |
children type |
React.ReactNode |
VNode | VNode[] |
Platform-specific |
useBoundProp |
exported | exported | Same API; returns [value, setValue] |
| Streaming hooks | useUIStream, useChatUI |
useUIStream, useChatUI |
Same API; returns Vue Ref values |
| Focus of a keyed move | react-dom gives the focus back | the focus goes to the body when Vue moves the focused node | As a hand-written v-for |
| A later watcher of an instance whose watcher throws | does not run | runs; the element then fails one time | Vue stops watchers at the unmount |
Order of the watch of the elements |
the order of the tree of now | the order of the mount | The order of the post watchers of Vue |
| Config of a path with more fields | the field whose layout effect runs last | the field that registers last in the order of Vue | |
| Drop of a field state | the render of the commit reads the state of before | each reader sees the drop at once | Vue reactivity |
Plain path of a hook in setup |
read at each render | stays the path of the mount | Pass a getter in a keyed row |
Native blur of the focused row in a keyed move |
react moves the rows with a lower old index | Vue moves the rows out of the longest increasing run | The keyed diff of Vue |
| Handlers of a node that the renderer removes | do not run | run for each event that the browser fires, and each emit runs its binding |
v-on binds a listener of the node |
| A later write of the hook of a field that went | writes the field state | writes no field state for a path that no field holds | A rule of the fork |
| A config that reads the touch of its own field | keeps the touch | the touch changes the config, and the new config starts the field fresh | Do not make a config depend on its own touch |
The table of the renderer differences in XMACHINES.md, and the page /docs/renderer-differences of the docs site, compare all five renderers and name the test that holds each difference.
Related Packages
Vue 3 renderer for Comark. Render Markdown with components at runtime, with stre...
Define component libraries, generate LLM system prompts, and render streaming Op...
Framework-agnostic MCP Apps host for CopilotKit - the app<->host protocol (ext-a...
Version History
Pre-releases (13)
| Version | Released | License |
|---|---|---|
| 0.21.0-xm.6 | Apache-2.0 | |
| 0.21.0-xm.5 | Apache-2.0 | |
| 0.21.0-xm.4 | Apache-2.0 | |
| 0.21.0-xm.3 | Apache-2.0 | |
| 0.21.0-xm.2 | Apache-2.0 | |
| 0.21.0-xm.1 | Apache-2.0 | |
| 0.20.0-xm.4 | Apache-2.0 | |
| 0.20.0-xm.3 | Apache-2.0 | |
| 0.20.0-xm.2 | Apache-2.0 | |
| 0.20.0-xm.1 | Apache-2.0 | |
| 0.19.0-xm.3 | Apache-2.0 | |
| 0.19.0-xm.2 | Apache-2.0 | |
| 0.19.0-xm.1 | Apache-2.0 |