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 addressA 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
- The expression evaluator — the language and its safety model.
- Entry fields —
seedandcallbacks, where ordering applies. - State & Scopes — the store the subscriptions attach to.