Skip to Content

State & Scopes

All state lives in scopes: a flat namespace of reactive containers that expressions read and set writes. An expression addresses state by scope name and field:

scopes.<scopeName>.<field> // e.g. scopes.root.searchTerm

What a scope is

A scope is one shallow Proxy over a set of fields, with no state library underneath. Everything under a field is plain data. root and the scopes you add are createProxyScope objects; an item scope is the runtime’s own (below).

const root = createProxyScope<{ count: number; user?: { name: string } }>({ count: 0 }); root.$$emitter.on("user", () => { /* … */ }); // subscribe to a field root.count = 1; // write — emits "count" root.$$set("user", { name: "Ada" }); // the same, as a call root.$$set("count", 0, { default: true }); // no-op — writes only if unset root.user.name = "Hopper"; // ⚠️ a plain write into plain data — nobody hears it

A write replaces one field and emits it. There is no deep write: to change part of an object, write the object. A seed step writes with default: true, so two entries seeding one field do not overwrite each other.

The root scope

scopes.root is always present and is the only app-wide scope: search terms, the active tab, which modal is open.

{ "key": "search", "component": "SearchInput", "seed": [{ "set": "scopes.root.searchTerm", "literal": "" }], "props": { "expr": "({ value: scopes.root.searchTerm })" }, "callbacks": { "onChange": [{ "set": "scopes.root.searchTerm", "expr": "evt.value" }] } }

Every other scope belongs to a list item or comes from the host. root belongs to the <RendererProvider>, not to one document, so every document under one provider shares it. That is how chat blocks share live state.

Item scopes

A list gives each item two scopes, named by as. With "as": "order":

  • scopes.order is the item, a window onto the element in the array, and nothing else.
  • scopes.$order is the row: the runtime’s read-only index, id and value, and the row’s own state.
{ "key": "orders", "component": "Card", "each": "scopes.root.orders", "as": "order", "keyBy": "id", "children": ["order-title"] } { "key": "order-title", "component": "Typography", "props": { "expr": "({ text: (scopes.$order.index + 1) + '. ' + scopes.order.customer })" } }

index is the zero-based position in the each result, and id the key keyBy picked (the index without keyBy). When the array holds strings or numbers, the item is value. An as cannot start with $.

A write to the item replaces it in the array. The item itself never changes. set: "scopes.order.price" does, behind the scenes:

scopes.root.orders = scopes.root.orders.map((o) => (o === thisOrder ? { ...o, price } : o));

The array is the one each reads: scopes.root.orders, also when each is scopes.root.orders.filter(...). In a nested list the write climbs: a line edit sets a new scopes.order.lines, which sets a new scopes.root.orders.

When each builds new objects, as orders.map(o => ({ ...o, total })) does, no array holds the item, and the write fails as unknown-reference. A primitive item cannot be written.

Scopes stack, so a list’s as must be a new name: not root, and not the as of a list it sits inside. A reused name fails as invalid-list in the list’s error slot. Lists side by side do not stack, so they may share a name.

// ❌ invalid-list: inside an order, "scopes.item" already exists { "key": "orders", "component": "Card", "each": "scopes.root.orders", "as": "item", "children": ["lines"] } { "key": "lines", "component": "Typography", "each": "scopes.item.lines", "as": "item", "props": { "expr": "({ text: scopes.item.sku })" } } // ✅ distinct names stack — a line sees root, order and line at once. { "key": "orders", "component": "Card", "each": "scopes.root.orders", "as": "order", "children": ["lines"] } { "key": "lines", "component": "Typography", "each": "scopes.order.lines", "as": "line", "props": { "expr": "({ text: scopes.order.id + ' / ' + scopes.line.sku })" } }

Per-row state

State about a row that is not its data, such as expanded or editing, goes in the row’s own scope, scopes.$<as>. It starts empty and never touches the item:

{ "key": "orders", "component": "Accordion", "props": { "literal": { "type": "multiple" } }, "children": ["order"] } { "key": "order", "component": "AccordionItem", "each": "scopes.root.orders", "as": "order", "keyBy": "id", "props": { "expr": "({ title: scopes.order.customer, open: scopes.$order.open })" }, "callbacks": { "onToggle": [ { "set": "scopes.$order.open", "expr": "evt.open" } ] } }

A write re-renders that row only. The state is kept by the row’s id, so it survives a reorder, a refetch, and a filter that hides the row and shows it again; it lasts as long as the list. The row and everything inside it can read it, and the rest of the page cannot.

State read outside the row

State that something outside the row reads or writes, such as a count of selected rows or “select all”, lives at root, keyed by the row’s id:

{ "key": "order-check", "component": "Checkbox", "each": "scopes.root.orders", "as": "order", "keyBy": "id", "seed": [{ "set": "scopes.root.selected", "literal": {} }], "props": { "expr": "({ checked: scopes.root.selected[scopes.$order.id] })" }, "callbacks": { "onChange": [{ "set": "scopes.root.selected", "expr": "({ ...currentValue, [scopes.$order.id]: evt.checked })" }] } }

selected holds one flag per order. The list’s seed runs once, not once per row. A write to the map wakes every row, but only rows whose props changed re-render.

Reading and writing

  • Reads are property access at any depth: scopes.root.count, scopes.order.total, scopes.root.user.address.city.
  • Writes happen only through set, in a seed step at mount or a callbacks step on an event. See assignments.

A set address is scopes.<scope>.<field>: never deeper, never a number.

{ "set": "scopes.root.rows", "expr": "listRows()" } // ✅ a field { "set": "scopes.root.filters", "expr": "({ ...scopes.root.filters, status: 'open' })" } // ✅ part of a field: write the field { "set": "scopes.root.filters.status", "literal": "open" } // ❌ rejected — deeper than a field { "set": "scopes.root.rows.0.qty", "literal": 3 } // ❌ rejected — a number

A write wakes every reader of the field, at any depth: writing filters wakes a reader of filters.status. See Reactivity.

Lifecycle

A seeded field persists for the life of the page, through re-renders and while its element is hidden. A field never seeded reads as undefined.

A seed is first-writer-wins: it assigns only if the field is still undefined.

{ "key": "panel", "component": "Card", "seed": [{ "set": "scopes.root.sort", "literal": "name" }], "children": ["label"] } { "key": "label", "component": "Typography", "seed": [{ "set": "scopes.root.sort", "literal": "price" }], "props": { "expr": "({ text: scopes.root.sort })" } }

Renders name: panel seeds before label mounts, so label’s seed is a no-op, not an error. The same set in a callbacks step would write price. This is also why a value the user changed survives a re-mount.

The host can also supply state. <RendererProvider init> preloads scopes.root once, before any entry evaluates, and can expose extra named scopes (read as scopes.userCtx.name): proxies the host keeps and can update later. Steps can write them too, as they write root.

Declare each extra scope in the note of getCommonInstructionsPartialPrompt, which lands as a ## Note under the prompt’s # Expression Context. The note is all the model sees of the scope, so spell out its shape.

Last updated on