Event delegation
Per-element addEventListener calls don’t survive morph re-renders for nodes the diff inserts or rebuilds. The fix is delegation: bind one listener at the morph root and dispatch via closest().
kerf ships two helpers — delegate() (which auto-promotes the well-known non-bubbling event types to capture phase under the hood) and delegateCapture() (the explicit-capture escape hatch) — plus one convention: data-morph-skip for subtrees neither helper should reach into.
5.1 The three-tier model
Section titled “5.1 The three-tier model”Almost every event you care about falls into one of three tiers.
Tier 1 — delegate()
Section titled “Tier 1 — delegate()”Default helper for “interactive thing happens on a descendant.” Works for both genuinely-bubbling events and the well-known non-bubbling ones.
Bubbling events handled directly: click, input, change, submit, mousedown/up, keydown/up, pointerdown/up/move, drag*, drop, contextmenu, wheel, copy/paste/cut, focusin/focusout.
Non-bubbling events that delegate() auto-promotes to capture phase under the hood: focus, blur, scroll, load, error, mouseenter, mouseleave. Selector matching stays closest()-style — same as for bubbling events — so a wrapper selector still matches when the event fires on a descendant.
import { delegate } from 'kerfjs';
delegate(rootEl, 'click', '[data-action="add"]', (e, btn) => { // `btn` is the matched element (the button), not the original target console.log('clicked', btn.dataset.id);});
// Auto-capture under the hood; the call site looks the same.delegate(rootEl, 'focus', '.field-row', (_e, row) => { row.classList.add('field-row--active');});Tier 2 — delegateCapture()
Section titled “Tier 2 — delegateCapture()”Explicit-capture escape hatch. After delegate()’s auto-promotion list expanded to cover focus / blur / scroll / load / error / mouseenter / mouseleave, the only remaining cases for delegateCapture() are:
- Custom non-bubbling events that a third-party library or your own code dispatches without bubbling, which
delegate()’s auto-promotion list doesn’t know about. - Capture-phase interception — you want the listener to run BEFORE any descendant’s bubble-phase handler sees the event.
- Strict element-match semantics —
delegateCapture()usestarget.matches()(noclosest()walk-up), so the handler fires only when the event lands on the exact element the selector identifies.
import { delegateCapture } from 'kerfjs';
// 1. Custom non-bubbling event from a third-party widget.// e.g. `xterm-resize` is dispatched on the terminal element and doesn't bubble:delegateCapture(rootEl, 'xterm-resize', '.terminal-host', (event, host) => { // host === the .terminal-host element; event was dispatched on it directly. void event; void host;});
// 2. Intercept clicks BEFORE the bubble-phase delegate handler sees them// (e.g. to validate before a "submit" handler runs).delegateCapture(rootEl, 'click', '[data-action="submit"]', (event) => { if (!isValid()) event.stopPropagation(); // bubble-phase handler won't fire});
// 3. Strict-match: only fire when the click lands ON the element, not a child.delegateCapture(rootEl, 'click', '.exact-target', (_event, exact) => { // descendant clicks won't trigger this — `target.matches('.exact-target')` is false. void exact;});In practice you almost never need delegateCapture() — delegate() covers the common cases. Reach for it only when the three scenarios above apply.
Tier 3 — per-element instances / library-owned subtrees
Section titled “Tier 3 — per-element instances / library-owned subtrees”xterm.js terminals, Monaco editors, D3/Plotly charts, embedded YouTube iframes, anything that owns its own children and would be corrupted if the diff recursed inside.
There’s no helper. The pattern:
- Render the host element with
data-morph-skiponce viamount(). - Mount the library imperatively into the host after the first render.
- Add direct event listeners on the library’s API (or on elements inside the host); they survive every parent re-render because the host is morph-skipped.
mount(rootEl, () => ( <div> <h2>Live chart</h2> <div id="chart-mount" data-morph-skip /> </div>));
const chart = new MyChart(document.getElementById('chart-mount')!);chart.on('select', (point) => { /* ... */ }); // direct listener — fineIntersectionObserver / ResizeObserver keyed to morph-replaceable elements are theoretically Tier 3 but uncommon in practice — observers usually attach to a stable parent and observe descendants generically.
5.2 Why closest() over target.matches()
Section titled “5.2 Why closest() over target.matches()”A click on an icon inside a button should fire the button’s handler, not the icon’s. closest() walks UP from the original target until it finds a matching ancestor — which is what you almost always want.
delegate() uses closest() for every event type, including the auto-promoted non-bubblers. So delegate(root, 'focus', '.field-row', ...) fires when a descendant <input> of .field-row receives focus, with the row as the matched element.
delegateCapture() uses target.matches() (direct match only). This is the escape-hatch behavior — useful when you want the listener to fire only when the event lands on the exact element the selector identifies, not any descendant.
5.3 Disposers
Section titled “5.3 Disposers”Both helpers return a () => void disposer. Capture it and call it when the delegate’s scope ends — the default rule, with exactly one narrow exception (described at the bottom of this section).
const offClick = delegate(rootEl, 'click', '[data-action]', handler);// later, when this scope is done:offClick();Why “capture by default”
Section titled “Why “capture by default””A delegate() call installs a listener on rootEl and returns a disposer that’s the only way to remove it. If you discard the disposer, four things can go wrong:
- Transient roots leak. Modals, route-level views, dynamically mounted widgets, popovers, micro-frontend slots — anything mounted and later torn down. The listener closure captures
rootEl,selector, andhandler. While that closure is alive, the detachedrootElis reachable, the handler is reachable, and everything the handler closes over (stores, signals, app state) is reachable. The listener doesn’t go away just because the element was removed from the DOM. - Re-mount cycles stack listeners. If you tear down the old root and mount a new one in its place without disposing, the previous listener stays on the now-detached element AND a fresh one attaches to the new element. Repeat the cycle and the leak grows linearly.
- GC timing isn’t a property you can design around. Even if the detached element is eventually collectable, you can’t predict when. Until it is, the listener — and everything in its closure — is still wired up. If the element ever re-enters the document (re-parented, held by a cache, referenced from a Map), the handler fires against state the rest of the app considers gone.
mount()does NOT clean up delegates for you. The disposer returned bymount()only stops the reactive effect and clears the mount marker. Anydelegate()you registered alongside the mount keeps its listener attached. If you re-mount()on the same root without first disposing the prior delegates, you stack listeners.
Canonical patterns
Section titled “Canonical patterns”For a transient root — store the disposers alongside the mount disposer and call both when the scope ends:
function openModal(host: HTMLElement) { const stopMount = mount(host, () => <ModalView />); const stopClick = delegate(host, 'click', '[data-action]', handleAction); const stopFocus = delegate(host, 'focus', 'input', handleFocus);
return function closeModal() { stopMount(); stopClick(); stopFocus(); host.remove(); };}Collecting into an array is fine too — whatever fits the surrounding code:
const disposers: Array<() => void> = [];disposers.push(mount(host, render));disposers.push(delegate(host, 'click', '[data-action]', onAction));disposers.push(delegate(host, 'keydown', '[data-edit]', onEdit));
function teardown() { for (const off of disposers) off(); disposers.length = 0;}See cart-htmx/main.tsx for a live example: its mount swap captures stopMount and stopDelegate and calls both before the next mount.
The narrow exception: genuinely page-lifetime registrations
Section titled “The narrow exception: genuinely page-lifetime registrations”If — and only if — all three of these are true, discarding the disposer is safe:
- The registration runs once at module top-level (or once during app bootstrap).
rootElisdocument.bodyor another element that lives for the page’s lifetime.- Nothing about the app’s design will ever tear it down and re-mount it.
In that case the listener is page-scoped by intent, and discarding the disposer mirrors that intent. The counter-store and chat example apps fall into this bucket — single mount at startup, no teardown, no re-mount. Everything else captures the disposer. When in doubt, capture; the cost of capturing a disposer you never need to call is zero, and the cost of not capturing one you did need is a leak that grows with use.
When capturing the disposer still isn’t enough
Section titled “When capturing the disposer still isn’t enough”Capturing the disposer is necessary but not always sufficient. Because kerf has no formal mount/unmount lifecycle by design — the call site is the only signal — a handful of patterns capture the disposer in a way that looks right and still leaks. Each scenario below has the same root cause: the lifetime the disposer is tied to is shorter than the lifetime of the variable holding it.
delegate() rooted on a node inside a morph-managed tree
Section titled “delegate() rooted on a node inside a morph-managed tree”delegate() attaches its single listener to the root element you pass it. If that root is itself inside another tree that the morph reconciles, the parent re-render can detach the root from the DOM without you knowing. The disposer reference you captured is still alive (held by the surrounding scope’s closure), but the element it points at is gone — and there’s no signal to tell you to call it.
// ❌ Wrong — `rowEl` lives inside an each() row. When the row is removed,// the listener is on a detached element and the disposer reference is stale.each(items.value, (item) => { const view = <li data-key={item.id}>…</li>; const rowEl = toElement(view) as HTMLElement; const off = delegate(rowEl, 'click', '.action', handleAction); // off is captured but the caller has no idea when to call it return view;});// ✅ Right — one delegate at the stable mount() root; closest()-style matching// dispatches to the right row. No per-row disposer to manage.mount(appRoot, () => ( <ul> {each(items.value, (item) => <li data-key={item.id}>…</li>, (i) => i.id)} </ul>));delegate(appRoot, 'click', '[data-row] .action', handleAction);Rule of thumb: root delegate() at the outermost stable element — the mount() root, or a data-morph-skip host — and let closest() matching reach inner targets.
delegate() called inside an effect()
Section titled “delegate() called inside an effect()”effect() re-runs its body every time a tracked signal changes. The effect’s disposer tears down the reactive subscription but not whatever side-effects the body produced. Every re-run installs a fresh delegate listener on the root; nothing removes the previous one.
// ❌ Wrong — every signal change adds another listener. The effect disposer// stops the subscription but the listeners stay attached forever.effect(() => { if (mode.value === 'edit') { delegate(root, 'keydown', '[data-edit]', commitOnEnter); }});// ✅ Right — register the delegate once at module / setup scope. Gate the// behavior on the signal inside the handler, where it's free.delegate(root, 'keydown', '[data-edit]', (e, el) => { if (mode.value !== 'edit') return; commitOnEnter(e, el);});This is the same shape as the addEventListener-inside-mount foot-gun (Hard Rule 4) wearing different clothes. Opt-in dev warn: set KERF_DEV_WARN_DELEGATE_IN_EFFECT=1 to surface this at runtime when it happens.
delegate() on a toElement() node that gets replaced
Section titled “delegate() on a toElement() node that gets replaced”toElement() returns a live DOM node you can appendChild / replaceChildren anywhere. If you attach a delegate to that node and later swap it out, the node is detached but the listener — and the disposer’s closure — are still in memory. This case doesn’t look like a transient root (there’s no mount()), so it’s easy to miss.
// ❌ Wrong — `card` is detached on the next replaceChildren(), but `off` is// either lost or stale. Listener and handler closure leak.const card = toElement(<div class="card">…</div>) as HTMLElement;delegate(card, 'click', '.btn', onBtn);host.replaceChildren(card);// …later…host.replaceChildren(toElement(<div class="card">…</div>));// ✅ Right — track the disposer alongside the node and call it before swapping.let off: (() => void) | null = null;function swapCard(jsx: SafeHtml): void { off?.(); const card = toElement(jsx) as HTMLElement; off = delegate(card, 'click', '.btn', onBtn); host.replaceChildren(card);}Disposer variables overwritten by reassignment
Section titled “Disposer variables overwritten by reassignment”A captured disposer that gets reassigned without being called first leaks the prior listener. The kerfjs/require-delegate-disposer lint rule passes — the call’s return value is assigned — but the leak is in the next statement after the rule’s window.
// ❌ Wrong — every store change overwrites `off` without calling the prior one.let off: () => void = () => {};store.subscribe(() => { off = delegate(root, 'click', currentSelector(), handler);});// ✅ Right — dispose the previous registration before reassigning.let off: () => void = () => {};store.subscribe(() => { off(); off = delegate(root, 'click', currentSelector(), handler);});If the reason you’re re-registering is “the selector changed,” consider whether one root-level delegate with a wider selector and an in-handler check fits — that pattern avoids the reassignment dance entirely.
Nested-root confusion: stable parent, transient child
Section titled “Nested-root confusion: stable parent, transient child”“Page-lifetime” is a property of the root element you pass to delegate(), not the surrounding app. A stable outer mount doesn’t make every descendant safe to ignore.
// ❌ Wrong — #app is page-lifetime, but `modalInstanceEl` is not. The modal// is created and removed on each open/close; discarding the disposer leaks// a listener per modal lifecycle.function openModal() { const modalInstanceEl = toElement(<div class="modal">…</div>) as HTMLElement; document.getElementById('modal-host')!.appendChild(modalInstanceEl); delegate(modalInstanceEl, 'click', '.close', () => closeModal(modalInstanceEl)); // disposer discarded — looks safe because the surrounding app is stable}// ✅ Right — the modal is transient. Capture the disposer and call it on close.function openModal() { const modalInstanceEl = toElement(<div class="modal">…</div>) as HTMLElement; document.getElementById('modal-host')!.appendChild(modalInstanceEl); const off = delegate(modalInstanceEl, 'click', '.close', () => { off(); modalInstanceEl.remove(); });}When deciding whether a delegate() is page-lifetime, ask: “is this specific element attached once and never removed?” If you can construct a code path that removes it, the disposer must be captured.
5.4 attr() — building selectors from typed constants
Section titled “5.4 attr() — building selectors from typed constants”When your data-action names live in a typed constant object, hand-writing the selector string as a literal is fragile — a rename of the action key doesn’t update the string. attr() creates a pre-computed descriptor that keeps the attribute name, value, selector, and a spreadable JSX object together.
Static form — fixed action names
Section titled “Static form — fixed action names”import { delegate, attr, type AttrSpec } from 'kerfjs';
const ACTIONS = { add: attr('data-action', 'add-todo'), remove: attr('data-action', 'remove-todo'), toggle: attr('data-action', 'toggle-todo'),} as const satisfies Record<string, AttrSpec<'data-action'>>;
// In JSX — spread .attrs (rename-safe; no hardcoded 'data-action' at call sites):// <button {...ACTIONS.toggle.attrs}>Toggle</button>
// In delegate — use the pre-computed selector:delegate(root, 'click', ACTIONS.toggle.selector, handler);// → '[data-action="toggle-todo"]'.attrs is { readonly 'data-action': 'toggle-todo' } — spreading it into JSX is type-safe and keeps the attribute name out of every call site. A rename of the action value propagates automatically through both the JSX and the delegate selector.
Dynamic form — per-row data attributes
Section titled “Dynamic form — per-row data attributes”For attributes whose value changes per item (like data-id), use the single-argument overload. The name is validated once; calling the returned factory is cheap.
const ITEM = { id: attr('data-id') } as const;
// In JSX — call the factory inline:// <li {...ITEM.id(String(item.id))}>…</li>
// Combine with a static action in one spread:// <button {...ACTIONS.toggle.attrs} {...ITEM.id(String(item.id))}>Toggle</button>The optional V generic constrains which values the factory accepts. Leaving both generics off infers N from the argument and defaults V to string; specify both explicitly when you want the type system to enforce the value set:
// Both generics off — N inferred as 'data-id', V defaults to string:const idFactory = attr('data-id');
// Both generics explicit — factory only accepts 'asc' | 'desc':const sortFactory = attr<'data-sort', 'asc' | 'desc'>('data-sort');The dynamic factory result is a plain frozen object { 'data-id': value }. It has no .selector (the value is unknown at definition time), so ad-hoc compound selectors still use string concatenation:
delegate(root, 'click', ACTIONS.toggle.selector + attr('data-id', id).selector, handler);// → '[data-action="toggle-todo"][data-id="42"]'Both forms CSS-escape at creation time (SSR-safe; no CSS.escape dependency). Hand-written string literals like '[data-action="add"]' are still fine for one-off selectors. attr() earns its keep when the attribute name and value both live in a typed constant that’s also referenced in JSX.
Generic type parameter
Section titled “Generic type parameter”Both delegate() and delegateCapture() accept an optional element-type generic that narrows the matched element in the handler, avoiding casts:
delegate<HTMLButtonElement>(root, 'click', 'button[data-action]', (_e, btn) => { // btn is HTMLButtonElement — no cast needed btn.disabled = true;});The default is Element, so untyped call sites are unaffected.
5.5 What you should NOT do
Section titled “5.5 What you should NOT do”-
Don’t
addEventListeneron individual rendered elements unless they’re inside adata-morph-skipsubtree. Listeners attached to nodes the diff rebuilds will silently disappear on the next re-render. -
Don’t worry about
mouseenter/mouseleavenot bubbling —delegate()auto-promotes them to capture phase. The call site is identical tomouseover/mouseout, but you get the cleaner enter/leave semantics (no fires on internal element transitions). -
Don’t try to compute “is this fresh DOM or preserved DOM” in a delegated handler — the handler doesn’t care. It just sees an event and a target.
-
Don’t read
e.targetto get the matched element — use the second argumentel.elis always the element matched by the selector (viaclosest()).e.targetis the element the event physically landed on. If your<button>contains a<span>, clicking the span givese.target = <span>—dataset.idwill beundefinedand the handler will silently do nothing.// Wrong — e.target can be a child <span>, not the <button>delegate(root, 'click', '[data-action="remove"]', (e) => {const id = (e.target as HTMLElement).dataset.id; // undefined when target is <span>remove(id!); // id is undefined, silent failure});// Right — el is always the matched [data-action="remove"] elementdelegate(root, 'click', '[data-action="remove"]', (_e, el) => {const id = (el as HTMLElement).dataset.id; // always correctremove(id!);});