Skip to Content
Component Entry FormatReactivity & Dependencies

Reactivity & Dependencies

You never declare what an expression depends on. Every scopes.<scope>.<field> read is extracted by static analysis, with the same parser that checks the expression for safety. Where the expression sits decides whether the reads drive subscriptions or ordering.

Reactive sites: subscriptions

props.expr, hidden, loading and each re-evaluate when state changes. Each read becomes a subscription to a field, and a write to that field re-renders exactly the elements that read it, at any depth.

{"key":"count","component":"Typography","props":{"expr":"({ text: scopes.root.products.length + ' products' })"}}

count subscribes to scopes.root.products. A set names a field, never an index inside it:

{ "set": "scopes.root.products", "expr": "listProducts()" } // ✅ replaces the array { "set": "scopes.root.products.0.name", "literal": "Renamed" } // ❌ rejected — not an address

A write to an item replaces it in its array. set: "scopes.row.qty" puts an edited copy of the item into a new scopes.root.products, so everything reading that array wakes. The list runs each again and keeps its other rows, whose items are the same objects. Only the edited row and readers whose result changed, such as a total, re-render. In a nested list the write climbs to the outermost array: a line edit sets a new scopes.order.lines, which sets a new scopes.root.orders.

One expression can subscribe to several fields:

{"key":"search","component":"SearchInput","seed":[{"set":"scopes.root.query","literal":""}],"props":{"expr":"({ value: scopes.root.query })"},"callbacks":{"onChange":[{"set":"scopes.root.query","expr":"evt.value"}]}} {"key":"rows","component":"Typography","each":"scopes.root.products.filter(p => p.name.includes(scopes.root.query))","as":"row","keyBy":"id","props":{"expr":"({ text: scopes.row.name })"}}

rows subscribes to scopes.root.products (a method call ends the path, so the dependency is the array) and to scopes.root.query. Typing writes query, and the list re-filters.

Effectful sites: ordering

seed and callbacks run once per trigger, so their reads drive ordering instead. Steps split into dependency waves: a step that reads a path an earlier step wrote, including through currentValue, goes in a later wave. Independent steps share one.

{ // … "seed": [ { "set": "scopes.root.city", "literal": "Amsterdam" }, { "set": "scopes.root.weather", "expr": "getWeather({ city: scopes.root.city })" } ] }

Two waves: weather reads city, so it waits. With nothing to wait for, both steps share one wave and run in parallel:

{ // … "seed": [ { "set": "scopes.root.products", "expr": "listProducts()" }, { "set": "scopes.root.categories", "expr": "listCategories()" } ] }

Callbacks add two barriers the analysis cannot infer. A step that calls a host function runs in a wave of its own, since a mutation’s effect is invisible to path analysis. A confirm step is a barrier too. Only callbacks do this: independent host calls in seed still run in parallel.

{ // … "callbacks": { "onClick": [ { "expr": "deleteProduct({ id: scopes.row.id })", "confirm": "Delete this product?" }, { "set": "scopes.root.products", "expr": "listProducts()" } ] } }

Two waves. The refetch reads nothing the delete writes, so path analysis alone would run them together.

A step writing scopes.row.qty counts as writing the row’s array, so a later step reading scopes.root.products waits for it.

The dependency is the field

Extraction is static and stops at the field:

{"key":"selected","component":"Typography","props":{"expr":"({ text: scopes.root.rows[scopes.root.sel].name })"}}

selected depends on scopes.root.rows and scopes.root.sel; a write to either wakes it. A computed key, a method call or a deeper property never narrows the subscription, so no write goes unheard.

No dependency loops

A reactive site cannot write. Only seed (at mount) and callbacks (on an event) write, and a render re-runs neither. So a document has no equivalent of a React effect that writes the state it reads.

An implementation can still loop, as in any React component: a useEffect that calls one of its callbacks on every render.

Where to go next

Last updated on