Skip to Content
Component Entry FormatEntry fields

Entry fields

A uicast document is JSONLines: one JSON object per line, each an entry describing one element. The engine mounts entries into a tree by key reference. The dynamic fields are JavaScript expressions over the document’s scopes.

Every field, used once:

{"key":"task-card","component":"Card","props":{"literal":{"title":"Tasks"}},"seed":[{"set":"scopes.root.tasks","expr":"listTasks()"}],"loading":"scopes.root.busy","children":["task-rows","empty-note","refresh"]} {"key":"task-rows","component":"Typography","each":"scopes.root.tasks","as":"task","keyBy":"id","props":{"expr":"({ text: scopes.task.title })"}} {"key":"empty-note","component":"Typography","hidden":"scopes.root.tasks.length > 0","props":{"literal":{"text":"No tasks yet"}}} {"key":"refresh","component":"Button","props":{"literal":{"text":"Refresh"}},"callbacks":{"onClick":[{"set":"scopes.root.busy","literal":true},{"set":"scopes.root.tasks","expr":"listTasks()"},{"set":"scopes.root.busy","literal":false}]}}

task-card is the root: no children array mentions it.

interface ComponentEntry { key: string; // unique id — required component: string; // catalog component name — required props?: ValueSource; // the props object hidden?: Expression; // truthy → hidden loading?: Expression; // truthy → busy children?: string[]; // child keys, in render order seed?: ValueSourceAssignment[]; // initial state, once at mount callbacks?: Record<string, CallbackValueSourceAssignment[]>; // event → steps each?: Expression; // array to iterate — its presence marks a list as?: string; // names the item's scopes keyBy?: string; // per-item identity field }

Structure

key

Required. A unique id, in kebab-case ("user-card", never "userCard"). Parents name children by it, and re-emitting a key replaces that subtree in place.

{ "key": "user-card", "component": "Card" }

component

Required. The catalog name, matched exactly against the renderer’s registry:

{ "key": "user-card", "component": "Card" } // ✅ in the catalog { "key": "user-panel", "component": "UserCard" } // ❌ Unknown component: UserCard

An unknown name renders the element’s error slot; the rest of the document is unaffected.

children

The keys of the entries inside this one, in the order they render. An entry with nothing inside has no children:

{"key":"row","component":"FlexRow","children":["name","email"]} {"key":"name","component":"Typography","props":{"literal":{"text":"Ada Lovelace"}}} {"key":"email","component":"Typography","props":{"literal":{"text":"ada@example.com"}}}

children is reserved: a definition may not declare it as a prop. Text goes in a prop named for what it holds, text above.

Value and state

props

A value source giving the props object, static or computed (wrap an object literal in parens). It is a reactive site: the engine subscribes to every path the expression reads. See Reactivity.

{ "props": { "expr": "({ text: scopes.root.title })" } } { "props": { "literal": { "variant": "outline" } } }

Props that fail the definition’s schema raise invalid-props in the element’s error slot, before render.

hidden

A bare expression, with no literal form, that hides the element while truthy. Reactive, like props.

{ "key": "details", "component": "Typography", "hidden": "!scopes.root.showDetails", "props": { "literal": { "text": "Ships in 2 days." } } }

hidden does not unmount: it hides the subtree inside React’s <Activity>.

loading

A bare expression, like hidden. While it is truthy the element is busy: the React binding hands it to the implementation as context.loading. Set it from the callback that refetches:

{ "key": "rows", "component": "Table", "loading": "scopes.root.busy", "children": ["head", "body"] } { "key": "search", "component": "SearchInput", "callbacks": { "onChange": [ { "set": "scopes.root.q", "expr": "evt.value" }, { "set": "scopes.root.busy", "literal": true, "debounce": true }, { "set": "scopes.root.rows", "expr": "listRows({ q: scopes.root.q })" }, { "set": "scopes.root.busy", "literal": false } ] } }

The host call is a barrier, so the flag clears after the rows land. A first load through seed shows the skeleton instead; loading is for refreshes.

seed

An array of assignments that initialize scope paths once, when the element first mounts.

{ "seed": [{ "set": "scopes.root.users", "expr": "getUsers()" }] }
  • Writes only undefined fields. A step skips its write when its set field already holds a value, so a seed never overwrites state.

  • Seed state, not derivations. Seed values that change later: form fields, selections, fetched lists. Compute derived values in props, hidden or each; a seed would freeze them at mount time.

  • Dependent seeds work. Steps run in dependency waves:

    { "seed": [ { "set": "scopes.root.tz", "literal": "UTC" }, { "set": "scopes.root.users", "expr": "getUsers()" }, { "set": "scopes.root.selected", "expr": "scopes.root.users[0].id" } ] }

    tz and users run in parallel. selected reads what users writes, so it waits for getUsers(). See lifecycle.

callbacks

A map from event name to an ordered list of steps, run when the component fires that event. A step is an assignment, optionally confirmed or debounced.

{ "callbacks": { "onChange": [ { "set": "scopes.root.q", "expr": "evt.value" }, { "set": "scopes.root.results", "expr": "searchTasks({ q: scopes.root.q })" } ] } }
  • Steps run in dependency order. A step that reads a path an earlier step wrote waits for it; independent steps run in parallel; a host-function step runs alone in its wave.

  • evt is the event payload, shaped by the component’s definition.

  • currentValue is the value now at the step’s set path, and confirm gates a step behind a Yes/No prompt:

    { "callbacks": { "onClick": [ { "set": "scopes.root.expanded", "expr": "!currentValue" }, { "confirm": "Delete this task?", "expr": "deleteTask({ id: scopes.task.id })" } ] } }

    Answering No skips the confirmed step and every step after it. Any step can have confirm, with or without set. This delete has no set because it runs only for its effect.

List fields

An entry with each is a list: its component renders once per item. There is no list container; the parent that names the list supplies it:

{"key":"task-table","component":"Table","children":["task-body"]} {"key":"task-body","component":"TableBody","children":["task-row"]} {"key":"task-row","component":"TableRow","each":"scopes.root.tasks","as":"task","keyBy":"id","children":["task-num","task-title"]} {"key":"task-num","component":"TableCell","props":{"expr":"({ text: scopes.$task.index + 1 })"}} {"key":"task-title","component":"TableCell","props":{"expr":"({ text: scopes.task.title })"}}

Three tasks give one <tbody> with three <tr>s. The list’s children repeat with it, each copy reading its own scopes.task and scopes.$task.

each

A bare expression returning the array to repeat over. Reactive: a write to a path it reads re-runs the list.

{ "each": "scopes.root.orders" } // a state array { "each": "scopes.root.orders.filter(o => o.status === 'open')" } // derived — still re-runs on every write to orders

Lists nest, one entry per level. A list inside another list repeats for each outer item, and its each can read that item:

{"key":"order-card","component":"Card","each":"scopes.root.orders","as":"order","keyBy":"id","props":{"expr":"({ title: 'Order ' + scopes.order.id })"},"children":["line-row"]} {"key":"line-row","component":"Typography","each":"scopes.order.lines","as":"line","keyBy":"sku","props":{"expr":"({ text: scopes.line.qty + ' × ' + scopes.line.sku })"}}

Each card lists the lines of its own order.

An each that evaluates to undefined renders zero rows, not an error.

as

The name of each item’s scopes, on the list entry and its whole subtree. scopes.<as> is the item itself. scopes.$<as> is the row: index, id (from keyBy), value for a primitive item, and the row’s own state. So an as cannot start with $. See State & Scopes.

as must be a new scope name: not root, and not the as of a list this one sits inside. A reused name fails as invalid-list in the list’s error slot:

{ "key": "order-row", "each": "scopes.root.orders", "as": "row", "children": ["line-item"] } { "key": "line-item", "each": "scopes.row.lines", "as": "row" } // ❌ invalid-list: inside order-row, scopes.row already exists

keyBy

Where the row’s id, the stable identity per item, comes from:

{ "each": "scopes.root.tasks", "as": "task", "keyBy": "id" } // { id: 7 } → 7; an item with no `id` falls back to its index { "each": "scopes.root.tasks", "as": "task" } // no keyBy → ids 0, 1, 2 …

Give object items a stable key. Without one, the id is the index, so an insert or a reorder moves row state to another item.

Partial replacement

Re-emitting an entry whose key exists replaces it and its subtree in place. It is the only patch verb, used for mid-stream corrections and edit turns. The new entry’s children decide what survives. Already streamed:

{"key":"task-card","component":"Card","children":["task-rows","refresh"]} {"key":"task-rows","component":"Typography","each":"scopes.root.tasks","as":"task","keyBy":"id","props":{"expr":"({ text: scopes.task.title })"}} {"key":"refresh","component":"Button","props":{"literal":{"text":"Refresh"}}}

Later in the same stream:

{"key":"task-card","component":"Card","children":["refresh","task-rows","empty-note"]} {"key":"empty-note","component":"Typography","props":{"literal":{"text":"No tasks yet"}}}

refresh and task-rows are not re-emitted, but the new children name them, so they survive with their subtrees, reordered. A key left out of children is dropped: omission is the delete.

State seeded above the replaced subtree is untouched, and the re-emitted entry’s seed does not re-run, so state the user changed survives. Use this to fix or restructure emitted UI; ongoing updates go through set.

Last updated on