intermediateVery common5 min read · Updated Jul 19, 2026

Implement Event Delegation (delegate with closest)

Implement delegate(root, selector, handler) in JavaScript with closest() and containment checks — bubbling mechanics, the focus/blur trap, and why frameworks delegated for years.


The problem

A list has 1,000 rows, each with a delete button. Attaching 1,000 listeners is wasteful — and rows added later have no listener at all. Implement delegate(root, eventType, selector, handler): one listener on the container that fires the handler as if it were attached to each matching descendant.

This is the DOM question with the best effort-to-signal ratio: the implementation is ten lines, and every line probes a piece of event-system knowledge — bubbling, target vs currentTarget, closest(), and the short list of events that don't bubble. It's also living history: jQuery's .on(sel, fn), and React's synthetic event system, are exactly this pattern.

The mechanism: bubbling

Events dispatched on a node travel down (capture phase), hit the target, then bubble back up through every ancestor. A listener on the container therefore hears clicks from every descendant — event.target tells you the actual clicked node, event.currentTarget the node the listener is on. Delegation is just: listen high, inspect target, act if it matches.

Implementation

function delegate(root, eventType, selector, handler) {
  const listener = (event) => {
    // target may be a deep child (the <svg> INSIDE the button) —
    // closest() walks UP to the nearest selector match
    const match = event.target.closest(selector);
 
    // Guard: closest() can walk PAST root to an outside match
    if (match && root.contains(match)) {
      // Call with `this` = the matched element, like a direct listener
      handler.call(match, event, match);
    }
  };
 
  root.addEventListener(eventType, listener);
  return () => root.removeEventListener(eventType, listener); // teardown
}
const undelegate = delegate(
  document.querySelector("#todo-list"),
  "click",
  "button.delete",
  function (event) {
    this.closest("li").remove(); // `this` = the matched button
  }
);
 
// Rows added later Just Work — no listener bookkeeping:
list.append(newRow);
// Cleanup on unmount:
undelegate();

The three lines that carry the marks

1. closest(), not === or matches() alone. The click's target is whatever was literally under the cursor — the icon <svg> inside your button, or a <span> inside that. event.target.matches(".delete") fails for the icon click; closest walks up to the nearest matching ancestor, which is the semantics a per-element listener would have had. This is the planted bug: an implementation that works when you click the button's padding and fails when you click its icon.

2. root.contains(match). closest doesn't stop at your container — it walks to the document root. If root is .sidebar and a matching .delete exists outside the sidebar in the ancestor chain (unusual but possible with portals/nesting), you'd fire for foreign elements. The containment check scopes the walk. Rarely hit, cheap to add, and knowing why it's there reads as having shipped this.

3. handler.call(match, ...). Direct listeners run with this = the element; matching that contract makes delegation a drop-in replacement (the this-binding thread). Passing match as an argument too spares arrow-function users.

The trap: events that don't bubble

Delegating focus, blur, mouseenter, or mouseleave silently does nothing — they don't bubble. The fixes:

  • focus/blur → use their bubbling twins focusin/focusout.
  • mouseenter/mouseleave → delegate mouseover/mouseout and filter with relatedTarget, or reconsider.
  • scroll on elements, media events (play, pause), and load also don't bubble — delegating them needs { capture: true } (capture phase visits ancestors even without bubbling).

This is the highest-yield follow-up in the question — "delegate focus for form validation" is the concrete scenario, and focusin is the password.

Where you see it in production

  • jQuery's .on(event, selector, fn) — this exact function, for a decade the dominant UI pattern.
  • React — all events were delegated to document (pre-17) then to the root container (17+); your synthetic onClick never attached a real listener to your button. The React 17 change (document → root) exists because two React apps on one page fought over document-level delegation — a great trivia-with-a-reason.
  • Infinite scroll / virtualized lists — rows mount and unmount constantly; delegation means zero listener churn (the row-pool + LRU world).
  • Analytics — one document-level delegate on [data-track] beats instrumenting every component.

The honest 2026 caveat: with component frameworks managing listeners and engines making addEventListener cheap, manual delegation is less common in app code — its value cases are dynamic lists, non-framework surfaces, and analytics layers. Calibrated relevance beats evangelism.

Edge cases interviewers probe

  • Click on a nested child of the match — the closest line; the core test.
  • stopPropagation in a descendant's own listener — the event never reaches your container: delegation depends on propagation, and third-party widgets calling stopPropagation are the classic "delegation mysteriously broken" ticket. (This is also the argument against sprinkling stopPropagation casually.)
  • Removed elements — clicking a button whose handler removes it: the event already bubbled with target intact; a second delegated handler running later sees match still valid but detached — root.contains(match) incidentally guards this too.
  • Dynamically matching selectors — an element that starts matching .delete.armed after a class toggle needs no re-wiring: matching happens per-event. The whole point, worth stating.
  • Shadow DOM — events crossing shadow boundaries get retargeted (event.target becomes the host); delegation across shadow roots sees the host, not the inner element — event.composedPath() is the escape hatch. One sentence of this is rare and impressive.

Common mistakes

  • event.target.matches(selector) without closest (fails on child clicks).
  • No containment check.
  • Delegating focus/blur and concluding delegation "doesn't work."
  • Returning nothing to remove the listener — leaks on unmount (the emitter-leak discipline).
  • Confusing target/currentTarget when explaining — the vocabulary is the assessment.

Follow-up questions

  • "Support multiple selectors/handlers on one root efficiently?" — one listener per event type + a registry of (selector, handler) pairs iterated per event — you're now building jQuery's internal event store; connects to the emitter's registry design.
  • "Capture-phase delegation — when?" — non-bubbling events (scroll, media), and intercepting before target handlers (analytics that must see cancelled events).
  • "Why did React delegate at all, and why less now?" — memory across huge trees + IE normalization then; post-17, container-scoped for coexistence, and modern engines shrank the benefit. History with reasons.
  • "Delegate input events for a form library?"input/change bubble — yes; combine with debounced validation and you've sketched how form libraries actually wire up.
  • "Measure: 1,000 listeners vs 1 delegate?" — memory per listener closure (what closures retain), attach/detach churn during renders, vs one indirection per event dispatch — the trade is allocation vs per-event work, and for UI event rates, delegation wins on churn-heavy DOMs.

  • Browser History

    intermediate

    Model back/forward/push navigation with an index and a stack — the core of every router.

  • Event Emitter

    intermediate

    Implement on, off, once, and emit — the pub/sub pattern behind Node streams and countless libraries.

  • Turn real DOM into a plain-object tree — the first half of understanding how React represents UI.