@json-render/vue
v0.16.0
Published
Vue renderer for @json-render/core. JSON becomes Vue components.
Readme
@json-render/vue
Vue 3 renderer for json-render. Turn JSON specs into Vue components with data binding, visibility, and actions.
Installation
npm install @json-render/vue @json-render/core zodPeer dependencies: vue ^3.5.0 and zod ^4.0.0.
Quick Start
1. Create a Catalog
import { defineCatalog } from "@json-render/core";
import { schema } from "@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(),
}),
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. children is a VNode | VNode[] — pass it directly to your container element.
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 "@json-render/vue";
import { catalog } from "./catalog";
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) =>
h("div", { class: "card" }, [
h("h3", null, props.title),
props.description ? h("p", null, props.description) : null,
children,
]),
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.
3. Render Specs
<script setup lang="ts">
import { StateProvider, ActionProvider, Renderer } from "@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
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": []
}
}
}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 "@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" },
],
});Composables
| Composable | Purpose |
|------------|---------|
| useStateStore() | Access state context (state as ShallowRef, get, set, update) |
| useStateValue(path) | Get single value from state |
| useStateBinding(path) | Two-way data binding (deprecated — use $bindState instead) |
| 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 visibleTypeScript helpers from @json-render/core:
import { visibility } from "@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 // falseDynamic 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 @json-render/core for full expression syntax.
Built-in Actions
The setState, pushState, removeState, 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 { VNode } from "vue";
interface ComponentContext<P> {
props: P; // Typed props from the catalog (expressions resolved)
children?: VNode | VNode[]; // Rendered children (for container components)
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 "@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 descriptionsFull Example
import { h } from "vue";
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/vue/schema";
import { defineRegistry, Renderer, StateProvider } from "@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>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 built-in actions: setState, pushState, removeState) |
| 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) |
| BaseComponentProps | Catalog-agnostic base type for reusable component libraries |
| EventHandle | Event handle with emit(), shouldPreventDefault, bound |
| ActionProviderProps | Props for ActionProvider |
| ValidationProviderProps | Props for ValidationProvider |
| ComponentFn | Component render function type |
| SetState | State setter type |
| StateModel | State model type |
| StateStore | Interface for plugging in external state management |
Differences from @json-render/react
| 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 |
