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 reverse declares its targets. A binding pairs a forward source with one

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);
onChange composes each target's new value and returns it, one per target

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);
};
Refuse a locked write, open a process and its transaction scope, build, settle, dispatch, prune

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);
};
Run each lens's onChange in target order and wire its outputs, down to the plain states

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);
  }
};
The second propagated flag: store it, and if it moved mark the readers

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);
};
A reverse locks from its inputs and targets; a binding takes its reverse's lock

_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);
};
A Pending made inside a transaction is counted on its process and stamped with it

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);
  }
};
A driver filling a counted Pending drops the counter and wakes its transaction

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;
};
At zero unresolved placeholders, land the whole store at once and unlock

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
Setting the binding writes celsius back, and the forward value re-derives

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
A reverse whose output is a Pending holds locked, and lands when a driver fills it

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.