reference · 2 of 5

Signals & scheduling

Signals are the state layer every binding sits on. This page covers the three primitives — ae.signal, ae.computed, ae.effect — the Reactive<T> union the helpers accept, and the scheduler around them: when a write lands, what re-runs, how to wait for the DOM to catch up, and how to animate the change.

ae.signal(initial)

Creates the one writable reactive container in ae: an object whose entire public surface is the property .value. Read it inside a tracked run and you subscribe; write it and every subscriber is scheduled. There is no subscribe method, no peek, no equality option to configure.

ae.signal<T>(initial: T): Signal<T>
initial
the starting value; any type, stored as given — never cloned, never made deeply reactive
returns
a Signal<T>. The class is exported too, so new Signal(0) is the same object
import { ae } from '@aeroapp/ae';

const count = ae.signal(0);
const draft = ae.signal('');
const todos = ae.signal([{ id: 1, text: 'Ship it' }]);

count.value++;        // write
todos.value.length;   // read
Shallow by design — a signal holds one reference. Nothing inside an object or array is reactive on its own, so state changes are expressed by replacing the value; see Signal.value.
Where to put them — a module-level signal is application state shared by every element bound to it. A signal created inside .scope is private per widget instance and dies with its root.

Signal.value

Reading .value inside a render, effect, computed or function-form helper subscribes that run to the signal. Writing it schedules every subscriber for the next microtask — three writes in one task produce one flush and one re-render. A write whose value is Object.is-equal to the current one is dropped before anything is scheduled.

get value(): T // tracked inside .render / .list / ae.effect / ae.computed set value(next: T) // Object.is-guarded; subscribers flush on the next microtask
get
the current value, plus a subscription whenever a tracked run is active
set
stores the value and queues subscribers; each affected run executes at most once per batch — three writes, one run
const count = ae.signal(0);

ae('count').render((el) => {
  el.textContent = count.value;   // tracked — this render is now a subscriber
});

count.value++;    // three writes in one task …
count.value++;
count.value++;    // … one flush, one render, on the next microtask

count.value = 3;  // already 3 — no-op, nothing is scheduled

The equality guard is why collections are replaced rather than mutated: a pushed array is still the same array, so the write never happens.

const todos = ae.signal([{ id: 1, done: false }]);

todos.value.push({ id: 2, done: false });   // same reference — silent
todos.value[0].done = true;                 // same reference — silent

todos.value = [...todos.value, { id: 2, done: false }];   // notifies
todos.value = todos.value.map((t) =>                      // notifies
  t.id === 1 ? { ...t, done: true } : t);
Gotcha — the DOM is one microtask behind. On the line after a write the element still shows the old text; use ae.settled() before reading it back. And the guard is Object.is, not ===: writing NaN over NaN is a no-op, while writing -0 over 0 counts as a change.

live · batching

count double renders

+3 in one tick writes three times and the render counter moves by one. Press reset to 0 twice: the second press writes a value the signal already holds, so the counter does not move at all.

ae.computed(fn)

A derived, read-only value. It is lazy: fn does not run until the computed is first read. After that a dependency write marks it stale and it re-evaluates once per flush — or on your next read, so a read immediately after a write is always fresh. Subscribers are notified only when the value actually changed, which is what keeps diamond-shaped graphs from causing spurious renders.

ae.computed<T>(fn: () => T): Computed<T>
fn
a pure derivation; every signal or computed read inside becomes a dependency of the last run
returns
a Computed<T>.value has a getter and no setter, and is readable everywhere a signal is
const price = ae.signal(24);
const seats = ae.signal(3);
const total = ae.computed(() => price.value * seats.value);

ae('total').text(() => `$${total.value}`);   // reactive, like any signal

seats.value = 4;
total.value;   // 96 — reading right after a write is always fresh
Equality cut-off — propagation stops at the first value that did not change. ae.computed(() => Math.min(width.value, 960)) re-evaluates when width goes from 1200 to 1400, but its result is still 960, so nothing downstream re-runs.
Keep fn pure — it can run when upstream values turn out unchanged, and a computed nobody currently subscribes to goes cold: dependency writes stop recomputing it until something reads it again. Side effects belong in ae.effect.
Errors — a throw while re-evaluating during a flush is logged as [ae] computed threw:; subscribers keep the last good value and the computed retries on the next dependency write or read. A throw during an explicit .value read propagates to whoever read it. Either way it never throws at the code that wrote the signal.

live · notify only on real change

renders:

The badge renders ae.computed(() => temp.value >= 21). Every press writes the temperature, but the render counter only moves on the press that crosses 21 — the one press where the computed's value differs.

ae.effect(fn)

An auto-tracked side effect that is not tied to an element. It runs fn immediately, re-runs it whenever a signal it read changes — batched, so three writes in one task produce one re-run — and returns a disposer. Reach for it for work outside the DOM — persistence, logging, firing a fetch when a filter changes.

ae.effect(fn: () => void): Cleanup // type Cleanup = () => void
fn
the effect body; its dependencies are re-collected on every run
returns
a Cleanup that unsubscribes the effect and cancels a run already queued for the current flush
const filter = ae.signal(localStorage.getItem('filter') ?? 'all');

const stopSaving = ae.effect(() => {
  localStorage.setItem('filter', filter.value);   // now, and on every change
});

ae('filter-all').press(() => { filter.value = 'all'; });

stopSaving();   // disposal is absolute — it never runs again
The one un-isolated error — if the initial run throws, the error propagates to your call site and the effect leaves no trace: nothing subscribed, nothing queued. Throws in later runs are logged as [ae] effect threw: and the rest of the batch still runs. Element bindings are isolated even on their first run — see .render.
Prefer .render for element work — an effect that writes to an element is not disposed when that element leaves the DOM, so it keeps running against a detached node. .render is the same machinery with the element's lifetime attached. An effect that writes a signal it also reads trips the runaway guard.

ae.isSignal(v)

True for Signal and Computed instances, false for everything else. This is the test the imperative helpers run to decide between applying a value once and wiring it reactively — and the test to run in helpers of your own that accept a Reactive<T>.

ae.isSignal(v: unknown): v is ReadableSignal<unknown> // Signal<T> | Computed<T>
v
any value
returns
a type guard narrowing v to ReadableSignal<unknown>
// A title helper that accepts a plain string, a signal, or a function.
function bindTitle(name, v) {
  ae(name).mount((el) => {
    if (ae.isSignal(v)) return ae.effect(() => { el.title = String(v.value); });
    if (typeof v === 'function') return ae.effect(() => { el.title = String(v(el)); });
    el.title = String(v);
  });
}
It is an instanceof check — a signal created by a second, separately loaded copy of ae fails it and would be treated as a plain value. Load one copy of the library per page.

ae.settled()

Resolves once pending signal writes — and everything they cascade into: list stamping, the mount pipeline, effects scheduled by those mounts — have drained and the DOM is final. It re-checks on a macrotask, so MutationObserver deliveries, which is how ae learns about new elements, land between the hops.

ae.settled(): Promise<void>
returns
a promise resolving when nothing is scheduled and no flush is in progress
todos.value = [...todos.value, { id: 4, text: 'Ship it' }];

await ae.settled();   // stamped, mounted, every cascade drained

// stamps sit at the end of the container, in item order
const rows = list.querySelectorAll('li');   // the <template> never matches
assert(rows.length === 4);
assert(rows[3].querySelector('[data-ae="title"]').textContent === 'Ship it');
What it waits for — ae's own scheduler, and nothing else: your fetches, timers, CSS transitions and image loads are invisible to it. It also costs at least one macrotask even when nothing is pending, so it is a tool for tests and orchestration, not for a hot path. ae.transition awaits it internally.

ae.transition(fn)

Runs fn inside document.startViewTransition, so every DOM change its signal writes cause — stamps, removals, reorders, text — is animated by the browser. The "new" snapshot is taken only after ae has settled: the flush and the mount pipeline, including writes that mount bindings cascade into. Where View Transitions are unsupported, fn runs plainly and the return value is undefined — an enhancement, never a requirement.

ae.transition(fn: () => void): ViewTransition | undefined
fn
the mutation — typically one or more signal writes
returns
the browser's transition object (ready, finished, updateCallbackDone, skipTransition()), or undefined on the fallback path
<div data-ae="deck">
  <template><span class="chip"></span></template>
</div>

ae('deck').list(chips, (el, chip) => {
  el.textContent = chip.label;
  el.style.viewTransitionName = `chip-${chip.id}`;   // unique name → morph
}, (chip) => chip.id);

ae('mix').press(() => ae.transition(() => {
  chips.value = shuffled(chips.value);
}));

The animation itself is pure CSS, and nothing in ae reads prefers-reduced-motion for you:

::view-transition-group(*) { animation-duration: 250ms; }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}
Give moving nodes a name — a unique view-transition-name makes the browser morph the old element into the new one, including across two .list containers, where the item is really a removal plus a fresh stamp. See animating changes, and the chip deck and kanban board on the tour for both, live.
Gotcha — on the supported path the browser decides when to invoke the callback, so the DOM is not necessarily updated by the time ae.transition returns. Await updateCallbackDone — or ae.settled() — if the next step depends on it.

Reactive<T>

The union the imperative helpers accept — .text, .cls, .attr, .show, and .list for its items. Three shapes, one rule: if the value can change, hand over something that can be read again.

type Reactive<T> = T | ReadableSignal<T> | ((el: HTMLElement) => T)
T
a plain value — applied once per element, when that element mounts
signal
a Signal<T> or Computed<T> — applied inside an effect, so it re-applies on change
(el) => T
a function of the element, run inside an auto-tracked effect: any signal it reads makes it reactive
const count = ae.signal(0);
const label = ae.computed(() => `${count.value} items`);

ae('badge')
  .attr('role', 'status')                    // plain — applied once
  .text(label)                               // computed — reactive
  .cls('empty', () => count.value === 0)     // function — auto-tracked
  .show(() => count.value > 0);

ae('row').text((el) => el.dataset.fallback); // the function gets the element
"Once" means once per element — a plain value is applied again to every element that mounts later under the same name, because the helper is a binding that runs per element. It is non-reactive only with respect to signals.
Gotcha — the function form is only as reactive as what it reads: () => someObject.count is read once and never again, () => countSignal.value re-runs. .cls is the one helper whose reactive argument is optional — with on omitted it is a one-shot classList.toggle.

Tracking rules & the runaway guard

There is no dependency array anywhere in ae. While a tracked run is executing, every .value read registers that run as a subscriber — and the dependency set is rebuilt from scratch on every run, so only the signals read on the last run are dependencies.

Tracked

  • .render(fn)
  • .list render callbacks, and the function form of its items
  • ae.effect(fn) and ae.computed(fn)
  • function-form helper values, (el) => value
  • ae.itemOf(el), read inside any of the above

Not tracked

  • event handlers — .press, .on, .hover
  • .mount(fn) and .scope(fn) bodies
  • anything deferred: setTimeout, a promise callback, code after an await
  • reads at module top level

Per-run collection has one consequence worth internalising: a branch that stops reading a signal stops depending on it.

const open = ae.signal(false);
const detail = ae.signal('loading…');

ae('panel').render((el) => {
  el.textContent = open.value ? detail.value : 'collapsed';
});

detail.value = 'ready';   // while `open` is false: nothing re-runs —
                          // the last run never read `detail`.

Reads in an event handler are the mirror image: they see the current value and subscribe to nothing, which is exactly what you want in .press(() => { count.value++; }).

A flush drains in cycles: each cycle takes the whole queue at once, so a run is batched to once per cycle — but a write made during that cycle queues its subscribers for the next one. An effect that reads what an earlier effect wrote therefore runs twice in a single flush, and the loop only ends when a cycle queues nothing.

Runaway guard — an effect that writes a signal it also reads would loop forever inside one flush. After 100 flush cycles ae clears the queue, logs [ae] update flush aborted after 100 cycles — an effect is probably writing a signal it also reads, and returns. The tab stays responsive; the computeds caught in the abort stay stale until the next dependency write or read. Treat it as a bug report, not a feature.
Do not nest effects — an ae.effect created inside a .render or another effect is a brand new effect on every re-run, and nothing disposes the old ones. Create effects in .mount or .scope, where you can return the disposer as the cleanup.

live · dependencies are per run

renders:

The render reads one of two signals, chosen by a third. While it displays A, pressing B +1 increments B — the value is waiting there when you swap — but the render counter does not move, because B was not a dependency of the last run.