Reactivity

The user interface and interaction are built with a small custom reactive framework. A value holds state. The page reads a value, and follows it. A write happens in an event handler. Learn these three moves and every module reads the same way.

Values

A value holds a piece of state. Sprites.Reactive.Value makes one from a starting value, and Sprites.Reactive.Get reads it. A Calculate reads other values and derives a new one, which updates on its own when a value it read changes.

const count = Sprites.Reactive.Value(0);
const doubled = Sprites.Reactive.Calculate(() => {
  return Sprites.Reactive.Get(count) * 2;
});

Bindings

A tag attribute or a text reads a value and updates in place when it changes. Hand a value straight to Text, or a function that reads one, and the page keeps it live. So the view is a plain description of the state, and it follows every change.

Sprites.Ui.Dom.Text(() => {
  return `Count ${Sprites.Reactive.Get(count)}`;
});

Writing values

Sprites.Reactive.Set writes a value, and takes a reactive value or a constant, never a function: read the old value with Freeze and compose the new one. Set only runs in an event handler or a lens, so writes stay where they belong, and the render code stays a pure description of the state. When the value changes, every binding that reads it updates.

const count = Sprites.Reactive.Value(0);

Sprites.Ui.Dom.Tag('button', () => {
  Sprites.Ui.Dom.Text(() => {
    return `Count ${Sprites.Reactive.Get(count)}`;
  });
  Sprites.Ui.Dom.Event('onclick', () => {
    const n = Sprites.Reactive.Freeze(count);
    Sprites.Reactive.Set(count, n + 1);
  });
});

Two directions

The framework runs in two directions. Forward builds the page and computes values from the top down, so a Calculate and a binding follow the values they read. Reverse settles a write from an event up to state, through the lenses. A lens is a value with an onChange, and its onChange runs in the reverse direction.

Reverse is value flow. In a lens you may read, compute, write with Set, start async work, and create nested lenses, to any depth. A nested lens runs in place, in the same reverse sweep, joining the transaction's reads, writes and async requests, so it needs no place in the tree. You may not build interface there, since there is no element to attach to; an If, Each or Repeat in a lens throws a clear error. Interface is built going forward: write a value, and the forward rebuild shapes it.

You may also compute on an async result inside a lens. The lens is normal reactive code: it composes reactive functions, which build live values, and ends in Set(target, value). When a value it sets is still on the way, that write joins the transaction frozen and pending, and commits when the value, and every other output, lands, all at once. The async is emitted once, so it never fires twice. So a lens composes nested lenses, async work, and computation over async results, in any combination.

Sprites.Reactive.Value(0, (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
});

Reactive modes

Nearly all code is reactive code. It composes reactive values and calls reactive functions, and it assumes every value it holds may be a reactive value, not plain data. There are three modes.

A rich library of reactive functions wraps Calculate for you, so application code never calls Calculate itself. You reach for a named function, and it derives the value. The 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);
};

Reading with Get and Freeze

Get reads a value and follows it. Use it inside a Calculate to derive a value that updates, and in a binding that shows a value. Freeze reads a snapshot and does not follow it. Use it in a lens or in reactive value code, where you want the value as it stands now. In a lens, read with Freeze, write with Set.

const step = Sprites.Reactive.Value(5);
const total = Sprites.Reactive.Value(0);
const add = Sprites.Reactive.Value(null, () => {
  const s = Sprites.Reactive.Freeze(step);
  const t = Sprites.Reactive.Freeze(total);
  Sprites.Reactive.Set(total, t + s);
});

Asynchronous operations

Async work lives in drivers, outside the synchronous core, so a slow fetch or a timer keeps calculations and handlers running. The framework tracks the work for you. A value can be pending, holding a result that is still on the way, and every binding that reads it waits, then updates the moment the value arrives.

Wrap the interface that reads a pending value in a Sprites.Ui.Loading boundary. It shows a spinner over the content while any value read inside stays pending, and reveals the content the moment the value arrives.

const result = Sprites.Reactive.Pending();
const started = Sprites.Reactive.Value(false);
Sprites.Ui.Button.SetValue(started, true, 'Load');
Sprites.Ui.Loading(() => {
  Sprites.Ui.Dom.Text(' result = ', result);
});
Sprites.Reactive.If(started, () => {
  Sprites.Animate.SetAfter(result, 'done', 1500);
});

A driver runs 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, so an idle driver frees its resources.

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);

The framework locks the interface to match the work. While a driver holds a value, that value is frozen, and any button or input that would write it disables on its own, then re-enables when the value lands. 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.Value(null, () => {
  Sprites.Animate.SetAfter(a, 'A from AB', 2000);
  Sprites.Animate.SetAfter(b, 'B from AB', 2000);
});
const setBC = Sprites.Reactive.Value(null, () => {
  Sprites.Animate.SetAfter(b, 'B from BC', 2000);
  Sprites.Animate.SetAfter(c, 'C from BC', 2000);
});
const setC = Sprites.Reactive.Value(null, () => {
  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); });
});
Press a button and watch the ones that share a value disable, then re-enable when the values land.