Reactivity
The interface and its interaction run on a small custom reactive framework, which uses reversible computing for event handling and state management, and has built in asynchronous handling and automatic locking.
Introduction
The framework runs in two directions. Going forward the page reads the state and follows it, so the view stays a live description of the state. Going back a write settles from an event into the state in one atomic step. Learn the two directions and a handful of combinators, and every module reads the same way.
One rule sits under all of it: every value is a live object the framework follows. You compose it with a library function and let the framework do the reading. The coding standards give the full rule and the wrong-to-right pairs.
The sections below build up in order. Values hold state and the view follows them; Calculations derive new values from them. Reverse is the write direction, and Bindings and Objects join the two directions into two-way views. Context and Gathering pass values down and up a subtree, and Locking and Drivers handle work that takes time.
Values
A value holds a piece of state. Sprites.Reactive.Value makes one from a
starting value. The view reads it and follows: hand Sprites.Ui.Dom.Text a value,
or a mix of fixed and reactive parts, and the page keeps it live. To show something derived,
compose it with a reactive function and hand that in.
const count = Sprites.Reactive.Value(0);
Sprites.Ui.Dom.Text('Count ', count);
The interface grows and shrinks with the data too. If shows content while a
value is true and Else while it is false; Each repeats content for
every element of a list, and Repeat a fixed number of times. Each
takes a reactive list, so rows appear and leave as the list changes, and the item it hands
the build is a two-way field.
const open = Sprites.Reactive.Value(true);
const items = Sprites.Reactive.Value(['Red', 'Green']);
Sprites.Reactive.If(open, () => {
Sprites.Ui.Dom.Tag('p', 'Shown while open');
});
Sprites.Reactive.Each(items, (item) => {
Sprites.Ui.Dom.Tag('p', item);
});
A value holds a plain snapshot, so a read hands it back, a change compares it with
===, and a field copy duplicates it. To hold an object whose fields are their own
values, compose it with Objects,
which keeps the live values outside as its sources.
Calculations
A Calculate
reads other values and derives a new one, which updates whenever a value it read changes.
Inside the callback, Sprites.Reactive.Calculate.Get reads a value and you use
plain operators, then return the result. The reader lives for the callback alone.
const count = Sprites.Reactive.Value(0);
const doubled = Sprites.Reactive.Calculate(() => {
const n = Sprites.Reactive.Calculate.Get(count);
return n * 2;
});
Calculate is the one trapdoor to raw JavaScript, and it belongs in a low level primitive in
one of six namespaces: Maths, Logic, Text,
Object, Array and Grid. A rich library of reactive
functions wraps it, so application code reaches for a named function. A derived value is live
in both directions: it feeds the view going forward, and inside a lens it is the value a
Set commits.
// compose named functions, never a raw Calculate: live both ways.
const withTax = (price) => {
return Sprites.Maths.Multiply(price, 1.2);
};
Reverse
Reverse settles a write from an event up into the state. Sprites.Reactive.Set
writes a value: compose the new value from the current values with a library function and hand
it in, and Set snapshots and commits it. Set runs in an event handler or a lens, so writes
stay where they belong.
const count = Sprites.Reactive.Value(0);
Sprites.Ui.Dom.Tag('button', () => {
Sprites.Ui.Dom.Text('Count ', count);
Sprites.Ui.Dom.Event('onclick', () => {
const next = Sprites.Maths.Add(count, 1);
Sprites.Reactive.Set(count, next);
});
});
A lens is a value with an onChange, made with
Reverse, that runs
in the reverse direction. In a lens you compose values, write with Set, start
async work, and nest lenses to any depth; a nested lens joins the same reverse sweep. The
structure combinators run here as value logic: an If, Each or
Repeat emits values into a gather. Interface is built going forward, so you write
a value and the forward rebuild shapes it.
A lens can compute on an async result. It composes reactive functions into live values and
ends in Set. When a value it sets is still on the way, that write joins the
transaction pending, and commits when the value, and every other output, lands together. The
async is emitted once.
Sprites.Reactive.Reverse((v) => {
const loaded = fetchSomething(v); // starts async once, returns a pending value
const total = Sprites.Maths.Add(loaded, 1); // a live value, pending until it lands
Sprites.Reactive.Set(target, total); // commits as (result + 1) when it lands
});
One atomic transaction
A whole reverse settle is one transaction. Every write buffers into a working store, and a lens reads the store, so it sees its own writes and the results of the lenses before it. The whole store then commits at once: every value it touched changes together. If a lens fails part way, the transaction rolls back and the state holds. A settle is all or nothing.
Bindings
A Binding joins a
forward source with a reverse write, giving a two-way view. Binding(source)
tracks the source, so it follows every change. Binding(source, reverse) adds the
set side, so a write runs the reverse, which composes the new value and Sets it on. This is
the shape for a control that keeps its value in bounds: hold the real value in a backing
value, and bind the control to a lens that clamps and writes it. The control follows the
backing value, so it snaps to the clamped value in the same settle.
const size = Sprites.Reactive.Value(16); // the backing value, the real size
const input = Sprites.Reactive.Binding(size, (v) => {
const held = Sprites.Maths.Clamp(v, 1, 256); // keep it in range
Sprites.Reactive.Set(size, held); // write the backing value; the input follows it
});
Sprites.Ui.Input.Number(input); // type 999, it settles at 256
Sprites.Ui.Dom.Text('size = ', size);
The same shape handles a value derived from a larger state. A field of a state object is a two-way view already, so a lens over one field normalises the whole object as it writes. The sprite editor uses this: the width control is a lens over the width field, and setting it resizes and clamps the whole grid, so the control follows the result.
To edit a value apart from its source, seed a draft with Value. Given a reactive source it takes the source's current value and detaches, so the draft edits on its own. This is the modal-edit pattern: seed the draft as the editor opens, Set the source from it on submit, and drop it on cancel. A Binding follows a source live, a Value snapshots it.
const draft = Sprites.Reactive.Value(source); // an independent value, seeded from source
Sprites.Ui.Input.Text(draft); // edit the draft, the source stays put
// on submit
Sprites.Reactive.Set(source, draft); // commit the draft to the source
Objects
A value may hold a whole object. When its fields are all fixed data, pass the plain object
straight to Value. When a field is its own reactive value, compose the object
with Sprites.Reactive.Object,
the one object constructor. You write the object literal the natural way, mixing constants and
live values at any depth, and it wires up the two-way behaviour: it reads as the whole object,
rebuilt whenever a reactive field changes, and a write fans back out to each reactive field,
keeping the fixed fields as they are.
It descends a nested literal to any depth, with
Reactive.Array as its
array sibling, Constant
for a fixed leaf, and ReadOnly
for the one-way form. Each leaf reads through Calculate.Get, so the assembled
object is a plain snapshot and the live values stay outside it as its sources. That snapshot
lets a read hand the object back, a change compare it with ===, and a field copy
it, while the two-way wiring holds.
const left = Sprites.Reactive.Value(12);
const width = Sprites.Reactive.Value(240);
const coords = Sprites.Reactive.Object({ left: left, top: 320, size: { width: width, height: 320 } });
Sprites.Ui.Input.Number(left, 0, 999);
Sprites.Ui.Input.Number(width, 0, 999);
const size = Sprites.Object.Field(coords, 'size');
const nestedWidth = Sprites.Object.Field(size, 'width');
const nestedLeft = Sprites.Object.Field(coords, 'left');
Sprites.Ui.Dom.Text(' left = ', nestedLeft, ', nested width = ', nestedWidth);
Context
Context injects a value for a whole subtree, read by any descendant however deep.
Context.Set(name, value, build) sets it for the length of build;
Context.Get(name, default) reads the nearest value in scope, or the default. A
nearer Set shadows a wider one. The core captures the context on every deferred callback, so a
Get resolves the same each time a part updates.
const theme = Sprites.Reactive.Value('dark');
Sprites.Reactive.Context.Set('theme', theme, () => {
// any descendant, however deep, reads it
const t = Sprites.Reactive.Context.Get('theme', 'light');
Sprites.Ui.Dom.Text('theme = ', t);
});
Gathering
Gather is the dual of context: context sets a value high and reads it deep; gather emits
values deep and collects them high. Sprites.Array.Gather(build) runs build and
collects into one reactive list every value Sprites.Array.Emit hands up inside
it, in tree order. Any reactive logic emits: a fixed value, an If that emits while
a condition holds, an Each that emits one per element, and the list follows as the
conditions and lists change. In a lens the gather runs once and the collected list is the
value a Set commits. This is the mechanism the interface itself runs on, where
each element gathers its children.
const extra = Sprites.Reactive.Value(true);
const list = Sprites.Reactive.Value(['a', 'b']);
const gathered = Sprites.Array.Gather(() => {
Sprites.Array.Emit('start');
Sprites.Reactive.If(extra, () => {
Sprites.Array.Emit('extra');
});
Sprites.Reactive.Each(list, (item) => {
Sprites.Array.Emit(item);
});
Sprites.Array.Emit('end');
});
Sprites.Ui.Input.Checkbox(extra);
Sprites.Ui.Button.Push(list, 'x', 'Add item');
const joined = Sprites.Text.Join(gathered, ', ');
Sprites.Ui.Dom.Text(' gathered = ', joined);
Locking
The framework locks the interface to match the work in flight. A value can be pending, holding a result still on the way, whether a transaction is settling on async work or a driver is filling it. While a value is held it is frozen: any button or input that would write it disables, then re-enables when the value lands. Wrap the interface that reads a pending value in a Sprites.Ui.Loading boundary, which shows a spinner while any value read inside stays pending, and reveals the content the moment it arrives. To drive a control's enabled state from a value's writability, compose it with Writable.
Because a reverse settle is one transaction, the locking is exact: every value the transaction would write is held together and lifts together. Try it below: each button sets some of A, B and C after a delay. Press one and the buttons that share a value with it disable until it arrives, while buttons that share nothing stay live. Press two that do not overlap and both run at once; a button they both touch stays disabled until both finish.
const a = Sprites.Reactive.Value('a');
const b = Sprites.Reactive.Value('b');
const c = Sprites.Reactive.Value('c');
const setAB = Sprites.Reactive.Reverse(() => {
Sprites.Animate.SetAfter(a, 'A from AB', 2000);
Sprites.Animate.SetAfter(b, 'B from AB', 2000);
});
const setBC = Sprites.Reactive.Reverse(() => {
Sprites.Animate.SetAfter(b, 'B from BC', 2000);
Sprites.Animate.SetAfter(c, 'C from BC', 2000);
});
const setC = Sprites.Reactive.Reverse(() => {
Sprites.Animate.SetAfter(c, 'C from C', 2000);
});
Sprites.Ui.Layout.Row(() => {
Sprites.Ui.Button.Act(setAB, 'Set A, B');
Sprites.Ui.Button.Act(setBC, 'Set B, C');
Sprites.Ui.Button.Act(setC, 'Set C');
});
Sprites.Ui.Layout.Row(() => {
Sprites.Ui.Loading(() => { Sprites.Ui.Dom.Text('A = ', a); });
Sprites.Ui.Loading(() => { Sprites.Ui.Dom.Text('B = ', b); });
Sprites.Ui.Loading(() => { Sprites.Ui.Dom.Text('C = ', c); });
});
Drivers
Async work lives in drivers, outside the synchronous core, so a slow fetch or a timer keeps
calculations and handlers running while the framework tracks the work.
Sprites.Reactive.Driver emits a request to a named driver, passing the value to
write and this request's parameters. The driver starts the work in onRequestAdd
and writes the result with Set, which opens the next update. The request lives
with the scope that asked for it, and onRequestDestroy cancels the work when that
scope ends. When the request is a reactive value, the driver follows it, rerunning with the
new value and cancelling stale work.
Any fact from outside the reactive world, the viewport size, the pointer, the clock, storage, reaches application code this way: a driver turns it into a reactive value, and application code composes that value. While a driver holds a value it is locked, exactly as a transaction locks one.
const search = () => {
return {
onRequestAdd: (binding, params) => {
const id = setTimeout(() => {
Sprites.Reactive.batch(() => {
const text = params.text.toUpperCase();
Sprites.Reactive.Set(binding, text);
});
}, 800);
return { onRequestDestroy: () => { clearTimeout(id); } };
},
};
};
Sprites.Reactive.Driver('demo.search', result, { text: 'hello' }, search);