Identity
The whole model rests on re-running the program and matching this run's output to the last one. For that to work,
every piece of state needs a stable identity: a way to say “this State is the
same one I saw last time,” so its value carries across. That identity is a path through a scope
tree, and the tree branches at every If and Each.
Why counting calls isn't enough
The obvious scheme is to number the identity-bearing calls in the order they happen: the first State
is 0, the next 1, and so on. That works only while the same calls happen in the
same order every run. The moment structure can change, it breaks:
const a = Reactive.State(0); // 0
Reactive.If(showExtra, () => {
const extra = Reactive.State(""); // 1 … only on runs where showExtra is true
});
const b = Reactive.State(0); // 2 on some runs, 1 on others — b's identity shifts!
When showExtra flips, the flat counter re-numbers everything after the If, so
b is mistaken for extra and loses its value. Once branches come and go, position in a
flat sequence stops being a stable identity.
The scope tree
A run walks a tree of scopes. Each scope hands out positions to the identity-bearing
calls inside it: State, and the junctions If/Each themselves. A junction
opens a named child scope rather than adding to its parent's sequence, and everything inside is
numbered relative to that child. A state's identity is the path from the root down to it:
Program // scope: root
const a = Reactive.State(0) // → root/0
Reactive.If(open, () => { // junction at root/1 → child scope "root/1/if"
const extra = Reactive.State("") // → root/1/if/0
})
Reactive.Each(items, (item) => { // junction at root/2 → one child per key
const n = Reactive.State(0) // → root/2/each:{item.key}/0
})
const b = Reactive.State(0) // → root/3 (always — the If never touched this counter)
Now b is root/3 whether or not the If ran, because the If
only ever consumed its own position (root/1); the state inside it lives one level down, in
a scope of its own.
How each junction branches
IfKeyed by position
An If opens a child scope keyed by its own place in the parent (so two different
Ifs never collide). While the condition holds, state inside is numbered in that child. When it
goes false the child stays unentered and its state is dropped; when it returns, the child is re-entered and
its state starts fresh: exactly the right lifecycle.
EachKeyed by element
An Each opens one child scope per element, keyed by the element's stable
key rather than its index. So a row's state travels with the row:
reorder the list and each row keeps its state; insert or remove and only the added or removed keys create or
drop a scope, leaving every other row untouched.
What the path buys
Because identity is a path rather than a position, state is isolated by branch: adding or
removing something in one part of the tree leaves the identity of everything in another part intact. Toggling an
If preserves the surrounding state and cleanly creates or discards only what's inside it. Reordering
a keyed list moves rows while their state stays put. And when the driver reconciles, it matches old to new by
these same paths, so it patches in place rather than rebuilding.
This is also what makes re-running the whole program cheap and correct rather than destructive: nothing is recreated just because its neighbours changed.
Status
State and every
emitted node take an id from the current scope's path and counter, and If/Each open
child scopes at their junctions, Each keyed by the element's stable key. The
Tasks example exercises it directly: rows keep their DOM across adds and removals.
Still on the design list: the full discovery pass that works out exactly which named contexts a
locked If/Each branch would reach. Today a locked branch surfaces its lock to the
nearest Loading boundary directly.