Handles & lifecycle
Everything in ae starts with a handle: you name elements in HTML with one attribute, ask for that name in JavaScript, and attach behavior to it. This page covers how a handle finds elements — now and later — how scoped handles keep identical names from colliding, how setup and teardown are paired, and the lifecycle rules the rest of the API rests on.
The markup contract
One attribute: data-ae="name". Names are free-form strings — no registry, no naming scheme, no reserved words — and the same name may appear on any number of elements. A handle is the set of all of them, so ae('row') means "every row", not "the row".
Nothing else in your HTML changes. There is no template syntax, no build step, and no hydration pass: the elements the browser parsed are the elements ae binds to, and view-source still shows the app.
<button data-ae="save">Save</button>
<span data-ae="status"></span>
// npm i @aeroapp/ae — or, with no tooling at all:
// import { ae } from 'https://esm.sh/@aeroapp/ae';
import { ae } from '@aeroapp/ae';
const saves = ae.signal(0);
ae('status').text(() => saves.value ? `saved ${saves.value}×` : 'never saved');
ae('save').press(() => saves.value++);
That is a complete ae app. The rest of this reference is the same two moves — name an element, attach behavior — applied to signals, rendering, forms and lists.
ae(name) → Handle
Returns the live handle for every element carrying data-ae="name" — the ones in the document now and any connected later. Liveness is not per handle: a single MutationObserver on document.body drives every mount, cleanup and data-ae rename on the page, however many names you use.
Handles are cached per name, so ae('x') === ae('x'). Every method returns the handle, so everything chains, and callbacks always receive the element first — fn(el, …) — which is what lets one handle serve many elements without you tracking any of them.
- name
- the
data-aevalue to match; any string, quotes and newlines included - returns
- the cached
Handlefor that name
<button data-ae="save">Save</button>
<button data-ae="save">Save and close</button>
const saves = ae.signal(0);
ae('save') // one handle, both buttons
.press(() => saves.value++) // methods chain …
.cls('ready', true); // … and each call appends a binding
ae('save') === ae('save'); // true — cached per name
.press twice on the same handle attaches two listeners, and every matching element gets both. Wire each name once at startup; if the setup has to re-run because its root comes and goes, that is what .scope is for.live
The r-chip bindings below were registered before a single chip existed. Append one and it binds itself; click a chip to remove it and its mount cleanup runs.
ae(name, root) → Handle
The same live handle, restricted to descendants of root. The root element itself never matches, even when it carries the same name — the same rule ae.parts follows.
Global and scoped handles compose rather than compete: an element inside root receives the bindings of ae('x') and of ae('x', root). Nested scopes stack the same way, innermost first. Scoped handles are cached per (root, name) pair, so ae('x', r) === ae('x', r) while ae('x', r) !== ae('x').
- name
- the
data-aevalue to match - root
- the element to look inside; only its descendants match
- returns
- the
Handlecached for that(root, name)
The point is per-container behavior without global name collisions. Scope to the container, and recover which item a handler fired for with ae.itemOf instead of stamping keys into the DOM:
<ul data-ae="todos">
<template><li><b data-ae="title"></b> <button data-ae="remove">×</button></li></template>
</ul>
ae('todos').scope((list) => { // once per container
ae('remove', list).press((btn) => { // only buttons inside THIS list
const todo = ae.itemOf(btn);
todos.value = todos.value.filter((t) => t.id !== todo.id);
});
});
.mount of a root that can be removed and re-added — use .scope, which retires what it created.root keeps its scoped bindings until it actually leaves the DOM or is renamed. Remove and re-insert it if you need a rebind.
.mount(fn)
Runs fn(el) exactly once per matching element — synchronously for elements already connected when you call it, and later for any element connected afterwards. If fn returns a function, that function is the cleanup and runs when the element leaves the DOM. Re-adding the element runs fn again.
This is the pairing point for anything the DOM cannot clean up for you: timers, observers, third-party widgets, subscriptions. Unlike .render, signal reads inside fn are not tracked — .mount is setup, not rendering.
- fn
- setup for one element; may return a
Cleanup(() => void) - returns
- the handle, for chaining
<time data-ae="clock"></time>
ae('clock').mount((el) => {
const tick = () => el.textContent = new Date().toLocaleTimeString();
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id); // runs when this element leaves the DOM
});
[ae] binding threw:, and isolated: the element's other bindings still attach, and every other element still mounts. A cleanup that throws is logged as [ae] cleanup threw:, and the remaining cleanups still run.
.scope(fn)
Like .mount, but built for per-root setup that creates scoped handles. Scoped handles first created inside fn — synchronously, during the call — are retired when the root unmounts, so a remount re-runs fn against fresh handles instead of stacking a second set of bindings onto cached ones.
That makes it the answer to "same component, many instances, private state". Each root gets its own signals in a closure and its own scoped handles; identical data-ae names inside two roots never see each other.
- fn
- setup for one root; may return a
Cleanupthat runs first at teardown - returns
- the handle, for chaining
<div data-ae="board">
<template>
<section data-ae="column">
<div data-ae="cards"><template>
<article data-ae="card"><b data-ae="card-title"></b> <button data-ae="card-del">×</button></article>
</template></div>
</section>
</template>
</div>
ae('column').scope((colEl) => { // once per stamped column
const { id } = ae.itemOf(colEl); // which column is this?
const mine = ae.computed(() => cards.value.filter((c) => c.col === id));
ae('cards', colEl).list(mine, renderCard, (c) => c.id);
ae('card-del', colEl).press((btn) => remove(ae.itemOf(btn)));
});
(root, name) map is first created inside fn are retired; a scoped map that already existed elsewhere survives the teardown untouched. The collection window is the synchronous run of fn, so scoped handles created later from a timer or a promise are not retired. A returned cleanup runs first, and the retirement happens even if that cleanup throws.live
Two roots, identical inner names (r-score, r-bump), one setup function — and a private count each.
panel A
panel B
.els
A plain array of the elements matching right now — an escape hatch to the raw DOM. An unscoped handle also includes matches inside shadow roots opted in via ae.observe, each element exactly once, because querySelectorAll alone cannot pierce a shadow boundary. A scoped handle contains only descendants of its root — the shadow-root merge applies to unscoped handles alone.
const rows = ae('row').els; // snapshot: a real Array, taken now
rows.length;
rows.at(-1)?.scrollIntoView();
// the usual reason to reach for it: get a root to scope to
const list = ae('todos').els[0];
ae('remove', list).press((btn) => remove(ae.itemOf(btn)));
.els is a snapshot, not a live collection and not reactive: it is computed on every read and never notifies anyone. For "every element, now and in the future", use .mount or .render. Read straight after inserting markup it does list the new elements — but their bindings have not attached yet, because the observer delivers on a microtask, after your code returns; await ae.settled() when you need both.
.each(fn)
Runs fn(el) over .els once and returns the handle, so it drops into a chain. It is the imperative twin of .mount: one pass over the elements that exist at this moment, no bookkeeping, no cleanup.
- fn
- called once per currently matching element
- returns
- the handle, for chaining
let renders = 0;
ae('count').render((el) => {
renders++;
el.textContent = count.value;
ae('renders').each((r) => r.textContent = renders); // write, don't bind
});
.each is not reactive and does not apply to elements added later; it is safe to call from inside a render or an event handler precisely because it registers nothing.
ae.parts(root)
Named lookup of the data-ae descendants of one element: ae.parts(el).title instead of el.querySelector('[data-ae="title"]'). The root itself is not a part, and on duplicate names the first match in document order wins.
The map is cached per root, which is what makes it cheap inside a .list render callback that re-runs on every item change. Part elements are ordinary elements — they still participate in global handles, so ae('title') binds all of them; ae.parts only gives you scoped access.
- root
- the element to look inside — typically a template-stamped node
- returns
- a null-prototype object mapping each
data-aename to its first element
<ul data-ae="todos">
<template><li><b data-ae="title"></b> <i data-ae="due"></i></li></template>
</ul>
ae('todos').list(todos, (li, todo, i) => {
const p = ae.parts(li); // cached: built once per stamped node
p.title.textContent = `${i + 1}. ${todo.text}`;
p.due.textContent = todo.due;
}, (todo) => todo.id);
live
This card reports its own parts map, filled through that map at mount.
keys of ae.parts(root)
'r-badge' in parts — is the root itself a part?
ae.itemOf(el)
The current .list item of the stamped node containing el. It walks up from el — the element itself counts — to the nearest stamped node, so nested lists resolve to the innermost item. Outside any stamped node, or once the item has been removed, it returns undefined.
This is how event handlers answer "which item was clicked" without stamping ids into the DOM: no data-id attributes, no parsing them back, no lookup table.
- el
- any element inside a stamped node (or the stamped node itself)
- returns
- the item currently rendered by that node, or
undefined
ae('remove').press((btn) => {
const todo = ae.itemOf(btn);
todos.value = todos.value.filter((t) => t.id !== todo.id);
});
// nested lists: a card's handler gets the card, not the column
ae('card-del').press((btn) => remove(ae.itemOf(btn)));
.render, a .list render callback or an ae.effect, it re-runs that effect when the node's item is replaced by key. Called from an event handler it is a plain read, like every other read in a handler.
ae.observe(root) → dispose
Extends liveness into a shadow tree. The document observer cannot pierce shadow boundaries — and neither can this one: nested shadow roots each need their own call. Marked content already inside mounts immediately if the host is connected, additions and data-ae renames inside the root behave exactly as they do in the document, removing the host unmounts the subtree, and re-inserting it remounts.
- root
- the
ShadowRootto watch (open or closed — you hold the reference) - returns
- a
Cleanupthat releases this call's reference
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = '<b data-ae="uc-name"></b>';
}
connectedCallback() {
this.stop = ae.observe(this.shadowRoot); // marked content mounts now
}
disconnectedCallback() {
this.stop(); // releases this reference
}
}
isConnected stays true: nothing watches it there any more. Moves between two observed roots are ordinary moves and do not remount.Semantics you can lean on
The rules every member on every page of this reference obeys. They are what make adding the attribute safe at scale.
Liveness
One MutationObserver on document.body powers mounts, cleanups, late elements and data-ae attributes added, removed or renamed after insertion — plus one observer per shadow root opted in via ae.observe. No per-handle observers.
Batching
Signal writes coalesce per microtask; each affected .render or effect runs at most once per flush. Three writes in one handler produce one re-render.
Absolute disposal
When an element leaves the DOM its mount cleanups run, its render effects are disposed and its listeners are removed. A disposed effect never runs again, even if it was already queued for the current flush. Re-adding the element re-binds everything.
Net-state lifecycle
Mount and cleanup reflect the net DOM change per task, not intermediate mutations: moving a connected element does not remount it, renaming data-ae a→b→a is a no-op, and several renames in one task bind the final name exactly once.
Fault isolation
A throwing binding, cleanup or effect re-run is logged with console.error and never stops the others. The one exception: an ae.effect whose initial run throws propagates to the caller and leaves no trace. Element bindings are always isolated, first run included.
Runaway guard
An effect that writes a signal it also reads trips a circuit breaker after 100 flush cycles — the queue is cleared and one console.error names the cause — instead of hanging the tab.
Templating stays native
.list stamps a real <template> element: no template syntax, no virtual DOM, and reconciliation is keyed node reuse over the nodes the browser already has. Everything ae renders is markup you could have written by hand.