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.addEventListener in setup requires a matching removeEventListener on 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.