Lifecycle and listeners
A component has four lifecycle tools, and the setup body itself:
const C = (props, { onMount, onUnmount, listen, unmount }) => {
// setup: runs ONCE, before the DOM exists
onMount((el) => {
const id = setInterval(tick, 1000); // el = the first root node, attached
return () => clearInterval(id); // the return value IS the cleanup
});
listen(document, 'keydown', onKey); // removed on unmount, automatically
onUnmount(() => save()); // runs on destroy
// unmount(): the instance destroys itself (toasts, dismissable banners)
return html`<div></div>`;
};
That is the complete surface. There is no update hook, because nothing re-renders: the setup body never runs again, and updates happen per binding.
Background
Two management responsibilities recur in component lifecycle:
- Setup vs updates. Code that runs on mount often requires a corresponding cleanup on unmount; code that runs whenever a value changes belongs in a subscription or computed value. Conflating them in one mechanism requires the author to disambiguate at call time.
- DOM listeners outside the component’s own elements.
document.addEventListenerin setup requires a matchingremoveEventListeneron destroy, with the same function reference; a listener armed mid-gesture (mousemove during a drag) needs removal on mouseup and on an unmount that happens mid-drag. A forgotten removal produces no error, just a listener calling into a dead component.
How LemonadeJS solves it
Setup runs once, and untracked. The component function is invoked a
single time, with dependency tracking suspended. This is deliberate: if
setup reads ran tracked, they would subscribe the branch binding that
happens to be materializing the component, and an unrelated state change
would rebuild the whole instance, re-introducing a re-render model.
The corollary is that a state read in the setup body is a one-time snapshot
(dev builds warn LJS-202 when that looks like a mistake); live
derivations belong in computed().
onMount(el) runs when the DOM is actually attached, so document.contains(el)
is already true, children before parents, so a parent can measure composed
children. One callback, two jobs by position: the body is the mount work,
the returned function is the cleanup. Non-function returns are ignored,
so single-expression callbacks stay legal:
onMount(() => opens.value++); // no cleanup, fine
onMount(() => open.subscribe((v) => v && place())); // subscribe returns its
// unsubscribe → auto-cleanup
That second line is a real idiom from the modal block: subscribe() returns
the unsubscribe function, which onMount registers as the cleanup, wiring
and unwiring in one statement.
onUnmount(cb) and every registered cleanup run on destroy, children
first, whether the destroy came from handle.unmount(), a branch replacing
the content, or a removed custom element. What “complete disposal” means,
and the heap-snapshot proof, is the Destroy chapter.
unmount() is self-destruction. The tool unmounts this instance:
children, bindings, cleanups, DOM: the full destroy path, idempotent. The
instance is flagged dead, and the reconciler never resurrects a dead
instance: if the owner re-renders the same position with the same data, a
fresh instance is built instead (the test suite pins this). Self-removal is
for presentation (a toast dismissing itself); permanent removal belongs
in the owner’s data.
listen() owns every listener the template cannot. Listeners on the
component’s own elements stay in the template, as in
onclick="$", and die with their nodes. Everything else
(document, window, another element) goes through the tool, and the
doctrine is absolute: raw addEventListener never appears in
component code.
const C = (p, { listen }) => {
listen(document, 'mousemove', onMove); // removed on unmount
const off = listen(window, 'resize', onResize); // off() removes earlier
// off() is idempotent and SELF-PRUNING: calling it removes the
// listener and withdraws its own unmount registration
};
listen works anywhere: setup, onMount, or mid-gesture inside an
event handler: arm document mousemove/mouseup on mousedown, call
off() on mouseup, and a mid-drag unmount still cleans up. A forgotten
removeEventListener is not writable, because there is nothing to
forget; removal is owned by the disposal that already owns everything
else. The engine suite pins the sharp edges: off() after unmount is
harmless, repeated gestures re-arm cleanly, an unmount mid-drag kills
the document listener, and multiple armed listeners all die at unmount
(the self-pruning regression test exists because the iteration bug it
guards was real).
Branches detach; they do not destroy. ${() => show.value && html…}
mounts its component when it first appears, exactly once. Hiding detaches
the DOM (one cached generation per slot); showing again reattaches the
same DOM without remounting, so onMount and refs do not re-fire. Work
that must happen per appearance is driven by the state that controls the
branch, the modal idiom above.
In practice
The modal’s gesture pattern: armed mid-event, one release slot.
Drag and resize each attach mousemove/mouseup to document, armed
inside the mousedown handler. The shipped source, on listen():
let releaseInteraction = null;
onUnmount(() => releaseInteraction?.()); // covers an unmount MID-DRAG
const track = (move, done) => {
releaseInteraction?.(); // one gesture in flight, ever
const offs = [
listen(document, 'mousemove', move),
listen(document, 'mouseup', () => releaseInteraction?.()),
];
releaseInteraction = () => {
offs.forEach((off) => off()); // off(): fire-once, self-pruning
releaseInteraction = null;
done?.();
};
};
listen(window, 'resize', () => refreshDock()); // component-lifetime listener
Two layers of the same guarantee: off() releases per gesture, and any
listener still armed when the modal dies, mid-drag included, is
removed by disposal. The destroy-stress suite unmounts mid-drag and
asserts the document’s listener count returns to baseline. The pattern
is gated, not stylistic.
The speeddial’s grace timer. Hovering away closes the fan after 150ms; re-entering cancels the timer, and so does unmount, because a pending timeout that fires after death is a classic leak-adjacent bug:
let grace = null;
const clearGrace = () => {
if (grace !== null) { clearTimeout(grace); grace = null; }
};
onUnmount(clearGrace); // never a timer outliving the fan
const onLeave = () => {
clearGrace();
if (fanned.value) grace = setTimeout(() => { grace = null; doClose(); }, 150);
};
One timer in flight, three exits (re-enter, fire, unmount), one cleanup.
Reference
const C = (props, { onMount, onUnmount, listen, unmount }) => { ... };
onMount((el) => cleanup?) // el: first root node, already attached;
// children mount before parents; a returned
// FUNCTION runs on destroy (anything else ignored)
onUnmount(cb) // runs on destroy, children first
listen(target, type, fn, options?) // any EventTarget; removed on unmount
// → off(): removes earlier; idempotent,
// self-pruning; works mid-gesture
unmount() // self-destroy: full disposal, idempotent;
// dead instances are never resurrected
const handle = mount(C, root); // { el, unmount() }
handle.unmount(); // owner-driven destroy
Rules worth memorizing: setup runs once and untracked, so derive with
computed(), never in the setup body; the onMount return value is the
cleanup; listeners on your own elements stay in the template, everything
else goes through listen(); raw addEventListener never appears in
component code; branch show/hide reattaches without remounting, so re-arm
per-open work from the controlling state. For what destruction guarantees
(and the receipts), read Destroy.