Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

62 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

thin-render

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.

Live demo β†’

Why

@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.

Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Spec   β”‚ ──▢ β”‚  Renderer    β”‚ ──▢ β”‚  Registry    β”‚
β”‚ (JSON    β”‚     β”‚ (walks tree, β”‚      β”‚ (type β†’      β”‚
β”‚  tree)   β”‚     β”‚  builds emit)β”‚      β”‚  component)  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚
                 β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”
                 β”‚    Store     β”‚
                 β”‚ (path-based, β”‚
                 β”‚  granular)   β”‚
                 β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚
                 β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”
                 β”‚   Handlers   β”‚
                 β”‚ (registered  β”‚
                 β”‚  at top)     β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key design invariants

  • StoreContext holds the store reference β€” never the state value. It never changes, so useContext never 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 uses useSyncExternalStore with a per-path snapshot. Changing /items/0/name re-renders only the component subscribed to that exact path.

Quick start

cd demo
npm install
npm run dev     # http://localhost:5173

Basic usage

import { 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} />

Two-way binding

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.

Actions

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 } }.

Repeat (arrays)

{ "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.

API

<Renderer>

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

Hooks

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

Store & utilities

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

Component contract (ComponentProps)

Source: renderer.tsx

interface ComponentProps {
  element: UIElement;          // current spec element
  children?: ReactNode;       // rendered child elements
  emit: (event: string) => void;  // dispatch bound actions
}

Spec types

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
}

Demo

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

Q&A

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.

About

Minimal spec-driven React UI renderer with granular per-path subscriptions.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages