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.
- initial
- the starting value; any type, stored as given — never cloned, never made deeply reactive
- returns
- a
Signal<T>. The class is exported too, sonew 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
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
- 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);
Object.is, not ===: writing NaN over NaN is a no-op, while writing -0 over 0 counts as a change.live · batching
+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.
- fn
- a pure derivation; every signal or computed read inside becomes a dependency of the last run
- returns
- a
Computed<T>—.valuehas 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
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.[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
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.
- fn
- the effect body; its dependencies are re-collected on every run
- returns
- a
Cleanupthat 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
[ae] effect threw: and the rest of the batch still runs. Element bindings are isolated even on their first run — see .render.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>.
- v
- any value
- returns
- a type guard narrowing
vtoReadableSignal<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);
});
}
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.
- 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');
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.
- fn
- the mutation — typically one or more signal writes
- returns
- the browser's transition object (
ready,finished,updateCallbackDone,skipTransition()), orundefinedon 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; }
}
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.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.
- T
- a plain value — applied once per element, when that element mounts
- signal
- a
Signal<T>orComputed<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
() => 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).listrender callbacks, and the function form of itsitemsae.effect(fn)andae.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 anawait - 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.
[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.live · dependencies are per run
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.