* autoFixSpec prunes children references to undefined elements
Dangling references are the dominant remaining first-attempt validation
failure in benchmarks, and models frequently fail to repair them even given
the exact error (observed: three repair turns, same dangling footer each
time). The renderer already skips missing children at runtime, so pruning
yields the identical rendered output while letting the spec validate. Each
removal is reported in fixes.
* Classify autoFixSpec fixes as lossy or lossless
Pruning a dangling child reference changes what renders; relocating a
misplaced field does not. Callers with a repair loop need to tell these
apart: accept lossless fixes silently, prefer re-prompting over lossy fixes,
and keep the lossy-fixed spec as a last resort. Adds fixDetails alongside the
existing fixes strings (additive, no signature break).
* Validate visible conditions in validateSpec; document filtered-list pattern
Malformed visible conditions (e.g. mixing $state and $item in one object)
silently evaluate to hidden at runtime: evaluateCondition dispatches on the
first recognized key and non-strict parsing strips the rest, so whole regions
of UI disappear with a valid-looking spec. Benchmarked worst case: a kanban
board that rendered zero task cards.
- core: VisibilityConditionStrictSchema (strict objects, exported)
- core: validateSpec rejects malformed visible with a repairable message
listing the valid forms (code: invalid_visible)
- framework prompts: FILTERED LISTS rule showing the repeat + per-item
visible pattern models keep reaching for and inventing syntax around
* Support filtered lists: repeat + $item visible on the same element
Models across vendors consistently write {repeat, visible: {$item: ...}} on
one container to mean a filtered list (kanban columns, status sections).
Previously $item had no meaning outside the repeat scope, the condition
evaluated false, and the whole region silently disappeared — the worst
visual failures in benchmarks were boards rendering zero cards this way.
Outside a repeat scope that spelling was always broken, so claiming it is
backward compatible: the renderer now applies such a condition per item,
preserving original indices for item state paths. Container-level $state
conditions and per-child $item conditions behave as before.
- core: conditionUsesItemScope helper (exported)
- react: RepeatChildren filters items by the container's $item condition
- framework prompts: FILTERED LISTS rule teaches the container spelling
- react: repeat-filter test suite (filtering, no-filter, $state container
visibility, per-child $item)
Other framework renderers (vue, svelte, solid, react-native) still evaluate
the container condition outside scope and should adopt the same semantics.
* Validate repeat containers: require children and matching state arrays
Two silent empty-region failures seen repeatedly in benchmarks, both passing
validation today: a repeat element with no children (nothing to clone per
item) and a repeat statePath pointing at a missing or non-array state value.
Both now fail validateSpec with repairable messages (repeat_without_children,
repeat_state_mismatch). State checks only run when the spec provides state;
runtime-fed state is unaffected.
* Address review: lossy-aware hook repair, react-only filtered-list rule, reuse getByPath
- ink/react-native useUIStream repair loops no longer accept lossy autofixes
unconditionally: lossless relocations apply immediately, pruned content
holds back while retries remain (so validation fails and the model repairs
the missing elements) and applies only as a last resort.
- FILTERED LISTS prompt rule removed from schemas whose renderers do not
implement the per-item filter yet (everything except react). Renderer
parity tracked as follow-up.
- repeat_state_mismatch validation reuses getByPath instead of a local JSON
Pointer lookup that skipped ~0/~1 unescaping.
* Address review: split mixed repeat visibility, apply lossless fixes eagerly
- splitRepeatVisibility (core, exported): AND-composed conditions on a repeat
container partition into a container gate ($state conjuncts, hides the
shell) and a per-item filter ($item/$index conjuncts). Mixed $or cannot
partition soundly and stays fully per-item, documented.
- react renderer uses the split, so {$and: [{$state gate}, {$item filter}]}
hides the empty shell when the gate is false instead of rendering a husk.
- autoFixSpec gains { lossy?: boolean } (default true, additive): ink and
react-native repair loops now apply lossless relocations immediately and
withhold only the pruning until retries are exhausted, matching the stated
intent.
* Address review: RN final-validation error path, repeat prune guard, docs
- react-native useUIStream mirrors ink: when retries are exhausted and the
spec still fails validation, report through onError instead of silently
calling onComplete with an invalid spec.
- autoFixSpec never prunes a repeat container to zero children; that would
trade missing_child for repeat_without_children and leave the last-resort
spec unrenderable. The dangling template reference stays visible to repair.
- Docs for the new surface: core README + skill (validateSpec issue codes,
fixDetails, lossy option), react README + skill and web visibility docs
(filtered-list pattern, mixed-condition splitting, framework support note).
@json-render/react
React renderer for json-render. Turn JSON specs into React components with data binding, visibility, and actions.
Installation
npm install @json-render/react @json-render/core zod
Quick Start
1. Create a Catalog
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/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
defineRegistry conditionally requires the actions field only when the catalog declares actions. Catalogs with actions: {} can omit it entirely.
import { defineRegistry, useBoundProp } from "@json-render/react";
import { catalog } from "./catalog";
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{props.description && <p>{props.description}</p>}
{children}
</div>
),
Button: ({ props, emit }) => (
<button onClick={() => emit("press")}>
{props.label}
</button>
),
Input: ({ props, bindings }) => {
const [value, setValue] = useBoundProp(props.value, bindings?.value);
return (
<label>
{props.label}
<input
placeholder={props.placeholder ?? ""}
value={value ?? ""}
onChange={(e) => setValue(e.target.value)}
/>
</label>
);
},
},
});
3. Render Specs
import { Renderer, StateProvider, ActionProvider } from "@json-render/react";
import { registry } from "./registry";
function App({ spec }) {
return (
<StateProvider initialState={{ form: { name: "" } }}>
<ActionProvider handlers={{
submit: () => console.log("Submit"),
}}>
<Renderer spec={spec} registry={registry} />
</ActionProvider>
</StateProvider>
);
}
Spec Format
The React renderer uses a flat element map format:
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": []
}
}
}
Contexts
StateProvider
Share data across components with JSON Pointer paths:
<StateProvider initialState={{ user: { name: "John" } }}>
{children}
</StateProvider>
// In components:
const { state, get, set } = useStateStore();
const name = get("/user/name"); // "John"
set("/user/age", 25);
External Store (Controlled Mode)
For full control over state, pass a StateStore to bypass the internal state and wire json-render to any state management library (Redux, Zustand, XState, etc.):
import { createStateStore, type StateStore } from "@json-render/react";
// Option 1: Use the built-in store outside of React
const store = createStateStore({ count: 0 });
<StateProvider store={store}>
{children}
</StateProvider>
// Mutate from anywhere — React will re-render automatically:
store.set("/count", 1);
// Option 2: Implement the StateStore interface with your own backend
const zustandStore: StateStore = {
get: (path) => getByPath(useStore.getState(), path),
set: (path, value) => useStore.setState(prev => { /* ... */ }),
update: (updates) => useStore.setState(prev => { /* ... */ }),
getSnapshot: () => useStore.getState(),
subscribe: (listener) => useStore.subscribe(listener),
};
When store is provided, initialState and onStateChange are ignored. The store is the single source of truth. The same store prop is available on createRenderer, JSONUIProvider, and StateProvider.
ActionProvider
Handle actions from components:
<ActionProvider
handlers={{
submit: (params) => handleSubmit(params),
cancel: () => handleCancel(),
}}
>
{children}
</ActionProvider>
VisibilityProvider
Control element visibility based on data:
<VisibilityProvider>
{children}
</VisibilityProvider>
// Elements can use visibility conditions:
{
"type": "Alert",
"props": { "message": "Error!" },
"visible": { "$state": "/form/hasError" }
}
ValidationProvider
Add field validation:
<ValidationProvider>
{children}
</ValidationProvider>
// Use validation hooks:
const { errors, validate } = useFieldValidation("/form/email", {
checks: [
{ type: "required", message: "Email required" },
{ type: "email", message: "Invalid email" },
],
});
Hooks
| Hook | Purpose |
|---|---|
useStateStore() |
Access state context (state, get, set, update) |
useStateValue(path) |
Get single value from state |
useStateBinding(path) |
Two-way data binding (returns [value, setValue]) |
useIsVisible(condition) |
Check if a visibility condition is met |
useActions() |
Access action context |
useAction(name) |
Get a single action dispatch function |
useFieldValidation(path, config) |
Field validation state |
useOptionalValidation() |
Non-throwing validation context (returns null if no provider) |
useUIStream(options) |
Stream specs from an API endpoint |
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 @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 // 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 (e.g. value, checked, pressed). Inside repeat scopes, use { "$bindItem": "field" } instead.
To render a filtered list (kanban columns, status sections), put repeat and an $item visibility condition on the same container: { "repeat": { "statePath": "/tasks", "key": "id" }, "visible": { "$item": "status", "eq": "todo" }, "children": ["task-card"] } renders one child per matching item. AND-composed $state conjuncts still gate the container itself. Components receive resolved bindings with the state path for each bound prop; use useBoundProp(props.value, bindings?.value) to get [value, setValue].
$template and $computed
{
"label": { "$template": "Hello, ${/user/name}!" },
"fullName": {
"$computed": "fullName",
"args": {
"first": { "$state": "/form/firstName" },
"last": { "$state": "/form/lastName" }
}
}
}
Register functions via the functions prop on JSONUIProvider or createRenderer:
<JSONUIProvider
spec={spec}
catalog={catalog}
functions={{ fullName: (args) => `${args.first} ${args.last}` }}
>
See @json-render/core for full expression syntax.
State Watchers
Elements can declare a watch field to trigger actions when state values change:
{
"type": "Select",
"props": {
"label": "Country",
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": {
"action": "loadCities",
"params": { "country": { "$state": "/form/country" } }
}
},
"children": []
}
watch is a top-level field on elements (sibling of type/props/children), not inside props. Watchers only fire on value changes, not on initial render.
Built-in Actions
The setState, pushState, removeState, and validateForm actions are built into the React schema and handled automatically by ActionProvider. They are injected into AI prompts without needing to be declared in your catalog's actions:
{
"type": "Button",
"props": { "label": "Switch Tab" },
"on": {
"press": {
"action": "setState",
"params": { "statePath": "/activeTab", "value": "settings" }
}
},
"children": []
}
validateForm
Validate all registered form fields at once and write the result to state:
{
"type": "Button",
"props": { "label": "Submit" },
"on": {
"press": [
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
{ "action": "submitForm" }
]
},
"children": []
}
Writes { valid: boolean, errors: Record<string, string[]> } to the specified state path (defaults to /formValidation).
Component Props
When using defineRegistry, components receive these props:
interface ComponentContext<P> {
props: P; // Typed props from the catalog (expressions resolved)
children?: React.ReactNode; // Rendered children
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 (e.g. bindings.value)
}
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 (
<a
href={props.href}
onClick={(e) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
}}
>
{props.label}
</a>
);
},
Use bindings?.value, bindings?.checked, etc. with useBoundProp() for two-way bound form components.
BaseComponentProps
For building reusable component libraries that are not tied to a specific catalog (e.g. @json-render/shadcn), use the catalog-agnostic BaseComponentProps type:
import type { BaseComponentProps } from "@json-render/react";
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
<div>{props.title}{children}</div>
);
Generate AI Prompts
const systemPrompt = catalog.prompt();
// Returns detailed prompt with component/action descriptions
Full Example
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { defineRegistry, Renderer } from "@json-render/react";
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 }) => <h1>Hello, {props.name}!</h1>,
},
});
const spec = {
root: "greeting-1",
elements: {
"greeting-1": {
type: "Greeting",
props: { name: "World" },
children: [],
},
},
};
function App() {
return <Renderer spec={spec} registry={registry} />;
}
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, validateForm) |
useStateStore |
Access state context |
useStateValue |
Get single value from state |
useBoundProp |
Two-way binding for $bindState/$bindItem expressions |
useActions |
Access actions context |
useAction |
Get a single action dispatch function |
useUIStream |
Stream specs from an API endpoint |
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 |
ComponentFn |
Component render function type |
SetState |
State setter type |
StateModel |
State model type |
StateStore |
Interface for plugging in external state management |