A ~700-line spec-driven React renderer with granular per-path re-renders. Edit one cell in a 1000-row table β only that one cell's component re-renders.
Built as a minimal alternative to @json-render/react, dropping AI streaming, Zod validation, directives, devtools, and multi-framework output. Just the rendering core, a path-based store, and an action system.
@json-render/react cascades re-renders across the entire element tree on any state change because three React contexts (State, Visibility, Actions) all subscribe to the full state. Editing one cell in a repeated structure re-renders every component. thin-render fixes this at the architecture level: the renderer subscribes to nothing, and leaf components subscribe to individual store paths via useSyncExternalStore.
ββββββββββββ ββββββββββββββββ ββββββββββββββββ
β Spec β βββΆ β Renderer β βββΆ β Registry β
β (JSON β β (walks tree, β β (type β β
β tree) β β builds emit)β β component) β
ββββββββββββ ββββββββ¬ββββββββ ββββββββββββββββ
β
ββββββββΌββββββββ
β Store β
β (path-based, β
β granular) β
ββββββββ¬ββββββββ
β
ββββββββΌββββββββ
β Handlers β
β (registered β
β at top) β
ββββββββββββββββ
- StoreContext holds the store reference β never the state value. It never changes, so
useContextnever triggers re-renders. - ActionContext holds handler map +
getState/setStateβ also stable. Dispatching an action never re-renders anything by itself. - ElementRenderer is
React.memo'd and subscribes to no state. It only re-renders when the spec changes. - Leaf components subscribe to individual paths via
useBound()/useValue()β each usesuseSyncExternalStorewith a per-path snapshot. Changing/items/0/namere-renders only the component subscribed to that exact path.
cd demo
npm install
npm run dev # http://localhost:5173import { Renderer, createStore, type Spec, type Registry } from "thin-render";
// 1. Define a spec (what to render)
const spec: Spec = {
root: "card",
elements: {
card: { type: "Card", props: { title: "Hello" }, children: ["greeting"] },
greeting: { type: "StaticText", props: { text: "World" } },
},
};
// 2. Build a registry (type β React component)
const registry: Registry = {
Card: ({ element, children }) => <Paper>{children}</Paper>,
StaticText: ({ element }) => <>{element.props?.text}</>,
};
// 3. Create a store (state lives here)
const store = createStore({});
// 4. Render
<Renderer spec={spec} registry={registry} store={store} />Components use useBound(path) to read and write a single store path:
import { useBound } from "thin-render";
function BoundField({ element }: ComponentProps) {
const [value, setValue] = useBound<string>(String(element.props?.bind));
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}The spec declares the path:
{ "type": "BoundField", "props": { "bind": "/user/name", "label": "Name" } }Changing this input writes to /user/name and re-renders only this BoundField.
Register handlers at the top, dispatch from components via emit:
// Handler (pure function of params + store API)
const handlers = {
saveDoc: (params, { setState }) => {
setState("/savedAt", new Date().toISOString());
},
};
// Spec binds an event to the handler
const spec = {
root: "btn",
elements: {
btn: {
type: "ActionButton",
props: { label: "Save" },
on: { click: { action: "saveDoc" } },
},
},
};The emit("click") closure is built per-element by the renderer, resolves params on-demand (read, not subscribe), and invokes the handler. Action params support:
{ $state: "/path" }β reads the current value from the store{ $item: "field" }β resolves to the absolute state path (e.g.,/items/5/field){ $item: "" }β resolves to the repeat base path (e.g.,/items/5){ $index: true }β resolves to the numeric repeat index
Built-in setState action is always available: { action: "setState", params: { path: "/flag", value: true } }.
{ "type": "Card", "repeat": { "path": "/items" }, "children": ["row"] }Renders row once per item in /items. Inside a repeat, components can use usePath() to get the current item's base path (/items/0, /items/1, etc.) β which is automatically composed with relative paths like useBound("name") to read from /items/0/name.
For stable React keys across re-renders, provide a key field on the repeat config pointing to a unique field on each item (e.g., "repeat": { "path": "/items", "key": "id" }). Without it, the array index is used, which breaks on reorder or delete. The unique ID must come from your data β thin-render does not auto-generate IDs.
Source:
renderer.tsx
| Prop | Type | Description |
|---|---|---|
spec |
Spec | null |
The declarative UI tree |
registry |
Registry |
Record<string, ComponentType<ComponentProps>> |
store |
Store |
Created by createStore(), stable reference |
handlers |
Handlers? |
Record<string, Handler> β action handlers |
loading |
boolean? |
Suppresses missing-element warnings during streaming |
Source:
hooks.ts,contexts.tsx
| Hook | Signature | Description |
|---|---|---|
useBound<T>(path) |
[T | undefined, (v: T) => void] |
Two-way bind to a path |
useValue<T>(path) |
T | undefined |
Read-only subscription to a path |
useSetValue(path) |
(v: unknown) => void |
Write-only setter for a path |
usePath() |
string |
Current repeat scope's base path |
Source:
store.ts
| Export | Signature | Description |
|---|---|---|
createStore(initial?) |
Store |
Create a path-based external store |
getByPath(state, path) |
unknown |
Read a nested value from an object by path string |
Store instance methods:
| Method | Description |
|---|---|
store.get(path) |
Read value at path |
store.set(path, value) |
Write path; no-op if value unchanged; notifies overlapping subscribers |
store.subscribe(path, fn) |
Register change listener; returns unsubscribe |
store.getState() |
Full state snapshot |
Source:
renderer.tsx
interface ComponentProps {
element: UIElement; // current spec element
children?: ReactNode; // rendered child elements
emit: (event: string) => void; // dispatch bound actions
}Source:
spec.ts
interface Spec {
root: string;
elements: Record<string, UIElement>;
}
interface UIElement {
type: string;
props?: Record<string, unknown>;
children?: string[];
on?: Record<string, ActionBinding | ActionBinding[]>;
repeat?: { path: string; key?: string };
}
interface ActionBinding {
action: string;
params?: Record<string, unknown>; // $state, $item, $index expressions
}The demo app (demo/) has thirteen self-contained cases:
| Case | What it shows | Source |
|---|---|---|
| Basic | Static spec rendering | BasicCase.tsx Β· spec.json Β· registry.ts |
| Form | BoundField inputs with read-only/editable toggle |
FormCase.tsx Β· spec.json Β· handlers.ts Β· registry.ts |
| Actions | ActionButton + handler writes timestamp to store |
ActionsCase.tsx Β· spec.json Β· handlers.ts Β· registry.ts |
| Large (1000) | 1000-row Γ 2-column editable table via repeat β edit one cell, only that cell re-renders. Per-row β delete using { $index: true }. |
LargeCase.tsx Β· buildSpec.ts Β· handlers.ts Β· registry.ts |
| Table | 1000-row HTML <table> with <thead>/<tbody> structure built via repeat |
TableCase.tsx Β· buildSpec.ts Β· handlers.ts Β· registry.ts |
| Switch | Conditional rendering via useValue β three mutually-exclusive status views |
SwitchCase.tsx Β· spec.json Β· handlers.ts Β· registry.ts |
| Detail Modal | Click a table row β async handler simulates backend call β detail data loads into a separate store path and displays in a Modal | DetailModalCase.tsx Β· buildSpec.ts Β· handlers.ts Β· registry.ts |
| Two Store | Two stores, two Renderers side by side β settings panel changes update a live preview only on Apply via a cross-store handler | TwoStoreCase.tsx Β· buildPreviewSpec.ts Β· buildSettingsSpec.ts Β· handlers.ts Β· registry.ts |
| Feature Flags | Dashboard with ToggleField, SliderField, Badge, Alert, and SegmentedField β showcases five Mantine-derived components | FeatureFlagsCase.tsx Β· buildSpec.ts Β· registry.ts |
| Translations | Editable translation strings via repeat on a plain object β demonstrates object key iteration with PathLabel and BoundField | TranslationsCase.tsx Β· buildSpec.ts Β· registry.ts |
| Drag & Drop | Sortable table with drag-and-drop reordering, add, and remove β powered by @dnd-kit with the store as source of truth | DndTableCase.tsx Β· buildSpec.ts Β· registry.ts |
| Mantine Table | Mantine-styled table with pagination β 300 rows, 10 per page, page state in store. Self-contained PaginatedTable component | MantineTableCase.tsx Β· buildSpec.ts Β· handlers.ts Β· registry.ts |
See Q&A.md for answers to common questions about $state, $item, and $index expressions β what they are, where they can be used, when to use each, how they cause re-renders, and how they compare to each other.