How does event delegation work in JavaScript? Why is it efficient
Delegation: one listener on a common ancestor handles events for many children. Works because events bubble up. Pros: fewer listeners (memory, setup cost), works for dynamically added children. Pattern: `element.addEventListener('click', e => { const item = e.target.closest('[data-id]'); if (!item) return; … })`.
Closely related to [[event-delegation-and-bubbling]] which has the deeper material.
How it works
Events bubble from the deepest target up the DOM. Instead of attaching a listener to every <li>, attach one to <ul> and identify the actual target:
<ul id="list">
<li data-id="1">A</li>
<li data-id="2">B</li>
<li data-id="3">C</li>
</ul>
<script>
document.getElementById("list").addEventListener("click", (e) => {
const li = e.target.closest("li[data-id]");
if (!li) return;
handleItem(li.dataset.id);
});
</script>e.target.closest(selector) walks up from the actual click target to find a matching ancestor.
Why efficient
- Memory — one listener vs N. For lists of thousands, N adds up.
- Setup time — attaching N listeners on render is slow; attaching one is O(1).
- Dynamic children — new items added later work automatically (no need to re-bind).
Common gotchas
- Icon inside button —
e.targetis the icon. Useclosest("button")to find the actual button. - stopPropagation in a child — kills delegation up the chain. Avoid unless necessary.
- Non-bubbling events —
focus,blur,mouseenter,mouseleavedon't bubble. Usefocusin,focusout,mouseover,mouseoutfor delegation.
When delegation isn't the right tool
- A small fixed set of elements — direct listeners are clearer.
- Need event-specific behavior the ancestor doesn't know about.
- Performance isn't actually a concern (most apps).
React's delegation
React 17+ delegates all events to the root container. onClick={fn} doesn't bind a real DOM listener per element; the root's listener dispatches. Same idea, baked into the framework.
Practical example: tabbed UI
tabs.addEventListener("click", (e) => {
const tab = e.target.closest("[role=tab]");
if (!tab) return;
selectTab(tab.dataset.target);
});One listener, works for tabs added later, ARIA-compatible.
Interview framing
"Events bubble from the deepest target up the DOM. Delegation puts one listener on a common ancestor and uses e.target.closest(selector) to identify the actual hit. It's efficient on three counts: less memory, faster setup, and it auto-handles dynamically added children. The classic gotcha is the icon-inside-button — e.target is the icon, not the button, so always use closest. focus/blur don't bubble; use focusin/focusout instead. React 17+ uses this pattern internally — every onClick delegates to the app root."
Follow-up questions
- •When is delegation NOT the right choice?
- •How does React's event system relate?
- •What's the difference between target and currentTarget?
Common mistakes
- •Reading e.target instead of using closest.
- •Stopping propagation and breaking delegation.
- •Trying to delegate non-bubbling events.
Performance considerations
- •Single listener O(1) setup. closest() is O(depth) per event — negligible for normal trees.
Edge cases
- •stopPropagation breaks the chain.
- •Composed paths in shadow DOM.
- •React synthetic vs native event ordering.
Real-world examples
- •jQuery .on('click', selector, fn), React's root listener, command palettes, virtualized lists.