Drivers
A program describes state. A driver is the boundary to the outside world. It takes the leftover emission streams. A name the program emits under but never gathers stays in the root scope's emissions. The driver does real work with it: paint the DOM, start a timer, run a fetch. It is also the one place a write starts from outside the graph. You register a driver by name when the program starts, in Root.
A driver is a sink for one stream
A driver is a plain function (requests, set), registered by name in the driver set that
Reactive.Root(program, drivers) takes as its second argument. requests is that name's
stream of emitted entries, the leftover emissions read by the driver's name. Whatever the program emits under
'dom' and never gathers arrives at the 'dom' driver:
const root = Reactive.Root(program, {
dom: (requests, set) => {
// requests is the 'dom' stream; paint each entry to the page
},
});
The write capability
Application code never writes into the graph directly. A write enters only at the boundary, and the boundary is
the driver. So Root builds a single set, bound to this root, and gives it to every
driver. That is the runtime's only write entry point from outside the graph:
Reactive.Root = (programCallback, drivers) => {
const previousRoot = Reactive._currentRoot;
const root = {
drivers: drivers || {},
rootScope: undefined,
nextReverseId: 1,
};
root.set = (reference, value) => {
return Reactive._write(root, reference, value);
};
Reactive._currentRoot = root;
try {
root.rootScope = Reactive._scopeCreate(undefined, programCallback);
Reactive._dispatchDrivers(root);
} finally {
Reactive._currentRoot = previousRoot;
}
return root;
};
Dispatch
_dispatchDrivers reads the root scope's final emissions, then for each registered driver name with a
non-empty stream, calls the driver with that stream's entries and the write capability. An emissions value is
{ pending, emissionsByName }, and emissionsByName maps each name to a stream
{ array, override }, so it reads stream.array and flattens it to the entries the driver
receives. Every entry is a real value, never a stand-in for a pending one, so a driver can act on each request it
is handed:
Reactive._dispatchDrivers = (root) => {
const rootEmissions = Reactive._value(root.rootScope.currentEmissionsRef);
Object.keys(root.drivers).forEach((driverName) => {
const stream = rootEmissions.emissionsByName[driverName];
const requests = stream === undefined ? [] : Reactive._arrayToList(stream.array);
if (requests.length > 0) {
root.drivers[driverName](requests, root.set);
}
});
};
The loop turns itself
Drivers run after every settle: once at Root, once after each write. A driver's set
runs another settle and another dispatch, so the loop continues by itself. That is what makes a
write from a driver reach the graph the same way any change does:
Reactive._write = (root, reference, value) => {
// ... admit the write to a state or run a reverse lens ...
if (isTopLevel) {
Reactive._update(root);
Reactive._dispatchDrivers(root);
}
// ...
};
This is how a Pending state unlocks. A Pending starts locked at a
stand-in value, and that pending flag flows through every calculation over it. A driver's set fills
the state, which unlocks it, and the next dispatch continues with the state now settled. The driver both
supplies the missing value and lets the loop continue.
An animation
An animation is a driver over a Pending frame. The frame starts locked at a stand-in, a calculation doubles it,
and both are pending while the frame is. The program emits the doubled value under 'display' and a
single request under 'animation'. When the 'animation' driver is called it schedules
frames; each scheduled tick calls set(frame, n), which fills the frame, unlocks it, and lets the
next dispatch continue:
const frame = {};
const frameQueue = [];
let started = false;
const root = Reactive.Root(() => {
frame.value = Reactive.Pending(0); // locked at 0
frame.doubled = Reactive.Calculate([frame.value], (n) => n * 2); // pending too
Reactive.Emit('display', frame.doubled);
Reactive.Emit('animation', 1); // one request kicks the driver off
}, {
animation: (requests, set) => {
if (started) return;
started = true;
[1, 2, 3].forEach((nextFrame) => {
frameQueue.push(() => { set(frame.value, nextFrame); }); // each tick fills the frame
});
},
display: () => {}, // paints the doubled value
});
frameQueue.shift()(); // set frame to 1: unlocks it, doubled becomes 2, dispatch runs again
A manual frame queue stands in for a real scheduler here, so the walk is easy to follow. In a running program
the ticks come from requestAnimationFrame or a timer, and the loop turns on their schedule instead
of on a call to shift.
Next Steps
With drivers the framework has a full circle: a program describes state, emissions carry its output to the boundary, drivers turn that output into effect, and their one write capability returns the world to the graph. Next, Scopes looks at how those blocks of code nest, appear, and disappear.