Reverse
Everything so far is the forward direction. A change enters at a state and flows out to everything that reads it, recomputed in order (see updates). The reverse direction is the other half, how a change flows back to state. Application code never writes a value directly. Instead a reverse declares its target references and returns their new values, and the runtime applies them itself. Because you name the targets before the write runs, the framework knows exactly what a write will set before it sets it.
Reverse and Binding
A Reverse is a write-only lens over a declared set of targets. Setting it runs
onChange(incoming) once, as a builder: it composes one new reactive value per
target, in target order, and returns them. It takes a single argument, the incoming value. Anything else it needs
it reads through its closure, and those reads are its inputs: values the write depends on but does not
itself target. Inputs are gates only. They decide whether the reverse can run at all (a pending
input locks it), never data the callback branches on. A reverse carries no forward value.
Reactive.Reverse(inputs, targetRefs, onChange) // onChange(incoming) → one composed value per target
Reactive.Binding(source, reverse) // a two-way view: read source, write through the reverse
A Binding is the two-way view. Its forward value tracks source, a plain passthrough, so
reading it follows every change to the source. Setting it runs the given reverse. This is the controlled-input,
derived-field shape: read a source, then write it back transformed onto real state.
Temperature is the small example. celsius is the real state. A reverse over [celsius]
turns a fahrenheit figure back into celsius, and fahrenheit is a binding whose forward value derives
fahrenheit from celsius and whose write side is that reverse. Reading fahrenheit tracks
celsius; setting it writes celsius back.
const celsius = Reactive.State(0);
const asFahrenheit = Reactive.Calculate([celsius], (c) => c * 9 / 5 + 32);
const toCelsius = Reactive.Reverse([], [celsius], (incoming) => {
const nextCelsius = Reactive.Calculate([incoming], (f) => (f - 32) * 5 / 9);
return [nextCelsius];
});
const fahrenheit = Reactive.Binding(asFahrenheit, toCelsius);
The value onChange returns is a composed reactive value, not a plain number. That is what makes the
reverse side a living graph: the returned value can read the incoming, read frozen inputs, and
read a Pending the callback creates, and it re-settles as those resolve. The whole write is built
once and then follows its inputs, exactly like the forward side.
One Write Path
There is a single internal write path, _write. It is not a public call. Application code never writes
directly. A write starts only from a driver, given a set bound to the root (see the
drivers page). Each write is one transaction: it opens a
reverse process to hold the working state, builds the reverse graph and the note that finishes it
inside a fresh scope, settles, then runs the drivers.
Two things happen up front. If the target is already write-locked the write throws: setting
something an in-flight transaction holds, or a lens gated on a pending input, is a caller error, refused before any
transaction opens. Otherwise the process is created. It carries the working store the build reads and writes, and a
pendingCount, the number of its own async values still unresolved, which decides when it finishes.
Reactive._write = (root, reference, value) => {
const previousRoot = Reactive._currentRoot;
const previousProcess = Reactive._currentProcess;
Reactive._currentRoot = root;
const isTopLevel = previousProcess === undefined;
if (isTopLevel && Reactive._writeLockedOf(reference)) {
Reactive._currentRoot = previousRoot;
throw new Error('cannot set a write-locked value or lens');
}
// a reverse composes values, it never calls set; a write can never nest inside another
if (!isTopLevel) {
Reactive._currentRoot = previousRoot;
throw new Error('a write cannot nest inside another write');
}
Reactive._currentProcess = {
latest: new Map(), // working store: target node → the latest value written to it
writtenStateRefs: new Map(), // the plain states this transaction will land
inputRefs: [], inputKeys: new Set(),
pendingCount: 0, // this process's own unresolved Pendings
transactionScope: undefined,
};
const hostNode = root.transactionsHostRef;
try {
const transactionScope = Reactive._scopeCreate(hostNode, () => {
Reactive._buildReverseDag(reference, Reactive._resolveInput(value));
Reactive._buildCommit(root, Reactive._currentProcess);
});
Reactive._currentProcess.transactionScope = transactionScope;
hostNode.payload.transactions.entries.push({ id: Reactive._mintEntryId(), value: transactionScope });
Reactive._update(root);
} finally {
Reactive._currentProcess = previousProcess;
Reactive._currentRoot = previousRoot;
}
Reactive._dispatchDrivers(root);
Reactive._pruneCompletedTransactions(root);
};
Every transaction lives in its own scope on the transactions host, one node near the top of the root scope that owns the live transactions exactly as an each owns its entry scopes. Because a transaction is a scope on that host, its work is walked by the update just like any other section, and a completed one is dropped from the host the same way. Keeping each transaction in a scope is what lets an async write hold across many walks and still land as one.
The Living Graph
_buildReverseDag runs onChange once as a builder and wires each value it returns to its
target. A target that is itself a lens (a Binding's reverse, or a lens over a lens) is queued and its
own onChange run in turn, so the write threads through nested lenses to the plain states underneath. A
target that is a plain state is recorded in writtenStateRefs: those are what the transaction lands.
Reactive._buildReverseDag = (topTargetRef, topIncomingRef) => {
const process = Reactive._currentProcess;
const wire = (targetRef, valueRef) => {
process.latest.set(targetRef, valueRef); // keyed by the target node itself; a later writer reads this
const reverseInfo = Reactive._reverseInfo(targetRef);
if (reverseInfo !== undefined) {
// a lens target: run its onChange in turn and wire its outputs
const updatedValues = reverseInfo.onChange(valueRef);
reverseInfo.targetRefs.forEach((ref, i) => { wire(ref, Reactive._resolveInput(updatedValues[i])); });
} else {
process.writtenStateRefs.set(targetRef, targetRef);
}
};
wire(topTargetRef, topIncomingRef);
};
Two reads inside onChange are special, so a lens that reads and writes the same value (a field lens
over an object) does not fight itself. A read of a target already written this transaction
resolves to its latest value in the working store, so writers chain and two field edits merge. A read of a declared
input resolves to a stable snapshot, so a self-referential read-modify-write does not wait on the
very value it is about to set. The real _resolveInput handles both; the shape above is the spine.
Write-Locking
A target can be held mid-write, and that fact must reach the top. So a node gains a second propagated flag,
writeLocked, stored beside pending. A state target locks while an in-flight async write
holds it. A reverse is locked when any of its inputs is pending or any target it writes is locked. A binding takes
its reverse's lock. Like pending, the flag propagates. _updateNodeWriteLocked stores it and, when it
moves, marks the node's readers, so the lock threads state → reverse → binding → whatever reads it. It mirrors
_updateNodeState exactly.
Reactive._updateNodeWriteLocked = (node, nextWriteLocked) => {
if (nextWriteLocked !== node.writeLocked) {
node.writeLocked = nextWriteLocked;
Reactive._updateMarkListenersChanged(node);
}
};
A reverse recomputes its own lock from its edges. Both its inputs and its targets are wired as node inputs, so a change to an input's pending or a target's lock reruns it. It is locked when any input it reads is pending, or when any target it writes is already held. The write cannot be composed while a value it reads is loading.
Reactive._updateReverseNodeInputs = (node) => {
const reversePayload = node.payload.reverse;
const writeLocked = Reactive._anyPending(reversePayload.inputRefs) || Reactive._anyWriteLocked(reversePayload.targetRefs);
Reactive._updateNodeWriteLocked(node, writeLocked);
};
Reactive._updateBindingNodeInputs = (node) => {
const bindingPayload = node.payload.binding;
const value = Reactive._value(bindingPayload.sourceRef);
const pending = bindingPayload.sourceRef.pending;
Reactive._updateNodeState(node, value, pending);
const writeLocked = bindingPayload.reverseRef.writeLocked;
Reactive._updateNodeWriteLocked(node, writeLocked);
};
_anyWriteLocked reads each node's stored flag without recursing, because the flag is kept current on
every node. Because the lock threads all the way up, a UI can ask Reactive.isWriteLocked(root, ref)
of whatever it renders, and know whether that thing can be written right now, direct from the stored flag.
Finishing: The Commit
After the graph is built, _buildCommit reads the working store for each written state, and builds the
commit note that finishes the transaction. Every destination is locked for writing. A destination
whose composed value is already ready is set now; one whose value is still pending is held at its prior value. The
note carries the destinations, their value nodes, the frozen inputs, and the process whose counter decides when to
land. It does not settle itself: the drain loop lands it when the process is done (below).
The gate is what keeps this simple. A reverse cannot even run while a value it reads is pending, because a pending
input write-locks the lens and the write throws before it starts. So onChange never
sees a loading value. The only thing that can still be pending inside a running transaction is a
Pending the callback creates itself, as an async output for a driver to fill.
The Pending Counter
An async write is a reverse whose output is a Pending it makes: a placeholder a driver will fill (a
saved id, a fetched body). Because a Pending created while a process is building belongs to that process, its
constructor counts it on the process and stamps the process onto the state, so the fill can find
the counter later. This is the whole of async: pendingCount is the number of the transaction's own
placeholders still open.
Reactive.Pending = (initialValue) => {
const process = Reactive._currentProcess;
const optionalCurrentProcess = process;
if (process !== undefined) {
process.pendingCount = process.pendingCount + 1; // so a driver's fill can find this counter
}
const payload = { state: { optionalCurrentProcess } };
const value = Reactive._value(initialValue);
return Reactive._nodeCreate(payload, value, [], true, false);
};
When a driver fills that placeholder, the state goes from pending to ready. _updateNodeState, the one
place a node's pending flag ever moves, notices a stamped state resolving, decrements the counter, and marks the
transaction's scope so the drain re-checks it. A placeholder that feeds no output has no readers to mark otherwise,
so this explicit mark is what still wakes the transaction.
Reactive._updateNodeState = (node, nextValue, nextPending) => {
if (nextValue !== node.value || nextPending !== node.pending) {
if (node.pending === true && nextPending === false) {
const stateInfo = node.payload.state;
if (stateInfo !== undefined && stateInfo.optionalCurrentProcess !== undefined) {
const process = stateInfo.optionalCurrentProcess;
process.pendingCount = process.pendingCount - 1;
stateInfo.optionalCurrentProcess = undefined;
Reactive._updateMarkScopeChanged(process.transactionScope);
}
}
node.value = nextValue;
node.pending = nextPending;
Reactive._updateMarkListenersChanged(node);
}
};
The transactions host walks its changed transaction scopes in the update. Right after a scope is settled, it lands the transaction if the counter has reached zero. With the counter at zero no output can still be pending; if one is, a pending value leaked without being counted, which is a bug, so it throws. Otherwise it sets every destination to its final value, releases the locks, and marks the transaction completed, to be pruned after the drivers run.
Reactive._finaliseIfSettled = (transactionScope) => {
const commitNode = Reactive._transactionCommitNode(transactionScope);
const commit = commitNode.payload.commit;
if (commit.completed || commit.process.pendingCount > 0) {
return; // still holds on one of its own Pendings
}
commit.valueRefs.forEach((valueRef) => {
if (Reactive._pendingOf(valueRef)) {
throw new Error('finalised at zero but a target value is still pending');
}
});
commit.destinations.forEach((d) => { Reactive._updateNodeState(d.node, d.valueRef.value, false); });
Reactive._releaseTransactionLocks(commit);
commit.completed = true;
};
A synchronous write creates no Pendings, so its counter is zero from the start and it lands on the first walk. An async write holds, its targets locked, until the last of its placeholders is filled, and then lands them all together. One transaction, one atomic landing, however many walks it waited across.
Running It
Setting the fahrenheit binding runs its reverse onto celsius, and the forward value re-derives from the settled celsius. The write went back to real state:
// celsius 0, fahrenheit reads 32
root.set(celsius, 100); // fahrenheit -> 212 the forward side follows its source
root.set(fahrenheit, 32); // celsius -> 0 the reverse runs onto real state
// fahrenheit -> 32 re-derived from the settled celsius
An async write is a reverse whose output is a Pending it creates. Setting it runs
onChange, which makes the placeholder and returns it: the target is held at its prior value and locked,
and the transaction holds on its one open placeholder. The lock threads up, so a competing write to the held target
throws. When a driver fills the placeholder, the counter reaches zero, the write lands, and the
lock releases.
let source;
const target = Reactive.State('old');
const saver = Reactive.Reverse([], [target], (incoming) => {
source = Reactive.Pending(''); // an async output a driver will fill
return [source];
});
root.set(saver, 'go'); // onChange makes source; target held + locked, transaction holds
Reactive.isWriteLocked(root, target); // true the lock threads up through saver too
root.set(target, 'clobber'); // THROWS a held target refuses a competing write
root.set(source, 'saved'); // target -> 'saved' filling the placeholder lands the write
Reactive.isWriteLocked(root, target); // false the counter hit zero, the lock released
Next Steps
That completes the framework. The forward graph reacts. A change enters at a state and flows to everything that reads it, in dependency order, each node touched at most once. Drivers bridge the outside, reading the root's leftover streams onto the screen and feeding external events back in. The reverse direction writes back. A lens declares its targets and returns their new values, and the runtime applies them, through one write path, inside one process.
Two flags thread through both halves. pending carries whether a value is still loading, forward from
a resource to everything that reads it. writeLocked carries whether a value can be written, up from a
held target through every reverse and binding to whatever a UI reads. A whole reactive program, a running graph
with both directions live, is built from exactly these pieces.