Reactivity

Azox has three primitives: signal, effect and computed. Everything reactive is built from them, and the whole runtime is about fifty lines.

signal

A signal holds a value and remembers who read it. Call it to read; call .set() to write.

signals
import { signal } from 'azox/reactivity';

const count = signal(0);

count();          // 0
count.set(5);     // write a value
count.set(n => n + 1);  // or derive from the current one
count();          // 6

Reading is a function call rather than a property. That is what lets a signal know it was read: there is no proxy and no getter interception involved.

peek

peek() reads without subscribing. Use it when you need the current value but do not want the surrounding effect to re-run on change.

peek
effect(() => {
  console.log(count.peek());  // reads, but creates no dependency
});

count.set(1);  // the effect above does not re-run

effect

An effect runs immediately, and again whenever a signal it read has changed. Dependencies are collected while it runs, so nothing is declared by hand.

effects
import { signal, effect } from 'azox/reactivity';

const first = signal('Ada');
const last = signal('Lovelace');

effect(() => {
  console.log(first(), last());
});
// logs immediately: Ada Lovelace

first.set('Grace');
// logs: Grace Lovelace

Setting a signal to a value it already holds does nothing. Effects only re-run on an actual change.

computed

A derived signal, recomputed when its sources change.

computed
import { signal, computed } from 'azox/reactivity';

const price = signal(20);
const quantity = signal(3);

const total = computed(() => price() * quantity());

total();            // 60
quantity.set(4);
total();            // 80

onMount

A script runs while its nodes are still being created, so anything that needs a real element has to wait. onMount runs once they are in the document.

measuring an element
import { signal, onMount } from 'azox/reactivity';

const width = signal(0);
let box;

onMount(() => {
  const measure = () => width.set(box.clientWidth);
  measure();

  window.addEventListener('resize', measure);
  // Returned from onMount, so it is this setup's cleanup.
  return () => window.removeEventListener('resize', measure);
});

onCleanup

Registers work to undo when the surrounding scope goes away — a timer to clear, a listener to remove, a subscription to close.

a timer that stops itself
import { signal, onCleanup } from 'azox/reactivity';

const ticks = signal(0);
const timer = setInterval(() => ticks.set(ticks() + 1), 1000);

onCleanup(() => clearInterval(timer));

A scope goes away when a row leaves a keyed list, or when an <if> takes the other branch. It also runs before an effect re-runs, so each run can undo the one before it.

At the top level of a page there is nothing that ever removes the scope, so onCleanup never runs there. That is a page living as long as the document, not a failure.

A cleanup that throws is reported to the console and the rest still run — a half-cleaned scope would leak whatever the others were holding.

How this reaches the DOM

This is where Azox differs from a Virtual DOM framework. A binding in your markup compiles into an effect that closes over one specific DOM node.

pages/index.azox
<script>
  import { signal } from 'azox/reactivity';
  const count = signal(0);
</script>

<p>Count: {count()}</p>

becomes, roughly:

compiled output
const _el0 = document.createElement("p");
const _el1 = document.createTextNode('');

effect(() => { _el1.data = "Count: " + String(count()); });

_el0.appendChild(_el1);

When count changes, that one effect runs and assigns to _el1.data. No component re-runs, no tree is rebuilt, and nothing is compared. The cost of an update is the cost of the assignment.

What this means in practice

You might expectIn Azox
Component re-renders on state changeNothing re-renders; the bound node is written to
Memoisation to avoid wasted rendersNot needed — there are no wasted renders to avoid
Dependency arraysDependencies are tracked as the effect runs
Keys to help a diffNo diff exists

Signals must be read inside markup or an effect to be reactive. Writing {count.peek()}, or copying the value into a plain variable first, produces a one-time read that never updates.

Next