Instead of attaching a listener to every button in a list, event delegation attaches one listener to a common ancestor and lets events bubble up to it. When the event arrives, you check whether it came from (or through) an element matching a selector, and handle it there. It's the classic pattern behind jQuery's .on(parent, selector, handler) and React's synthetic events — cheaper (one listener, not N) and it automatically covers elements added after you wired it up.
Implement eventDelegation(root, eventType, selector, handler). Attach a single listener to root; when an eventType event bubbles up from a descendant matching selector, call handler(event, matchedElement) with this set to the matched element. Return a cleanup function.
function eventDelegation(root, eventType, selector, handler) {
// returns a cleanup function that removes the listener
}
// One listener handles every .item, including ones added later.
const off = eventDelegation(list, 'click', '.item', (e, el) => {
console.log('clicked item', el.dataset.id);
});
off(); // stop listening
closest finds the match — the real event.target may be a child of the selector match (e.g. an icon inside a button); use event.target.closest(selector) to walk up.root (root.contains(matched)).removeEventListener.You'll add one listener to root whose callback runs event.target.closest(selector), and if that match exists inside root, invokes the handler bound to it.
Bubbling means an event fires on the deepest element it hit, then travels up through every ancestor, firing their listeners too. Delegation exploits that: put a single listener on a shared ancestor, and let every descendant's event come to you. When it arrives, event.target is the exact element clicked — but that might be an icon inside the button you care about, so you walk up with closest(selector) to find the nearest matching ancestor. If that match is inside root, it's a hit. This costs one listener regardless of how many items exist, and it keeps working for items added later, because they bubble to the same ancestor.
Stand at the root and watch events float up from below. For each one, ask: "starting at the element that fired it, is there a selector match on the way up to me?" event.target.closest(selector) answers that in one call — it returns the element itself or the nearest ancestor that matches, or null. Guard with root.contains(match) so you don't react to a match that lives above root. On a hit, call the handler with the matched element as both an argument and this.
The naive version compares event.target directly and attaches per element:
function eventDelegationNaive(root, type, selector, handler) {
root.querySelectorAll(selector).forEach((el) =>
el.addEventListener(type, handler), // one listener PER element
);
}
Two problems. It adds N listeners (one per current match), so a 10,000-row table wires 10,000 handlers. And it only sees the elements that existed at wiring time — anything added later has no listener, which breaks the exact use case delegation is for (dynamic lists). A single listener on root plus closest fixes both, and matching by closest also handles the "clicked a child of the target" case that a direct event.target === el check misses.
function eventDelegation(root, eventType, selector, handler) {
const listener = (event) => {
// Walk up from the real target to the nearest selector match.
const match = event.target.closest(selector);
if (match && root.contains(match)) {
handler.call(match, event, match); // `this` = match, + pass it explicitly
}
};
root.addEventListener(eventType, listener);
return () => root.removeEventListener(eventType, listener);
}
module.exports = { eventDelegation };
One listener goes on root. When an event bubbles up, event.target.closest(selector) returns the matched element (the target itself or an ancestor) or null. The root.contains(match) guard ensures the match is genuinely inside our subtree — closest could otherwise return an ancestor above root. On a hit we handler.call(match, event, match) so the handler gets this === match (jQuery-style) and the matched element as a second argument. The returned cleanup removes the exact listener we added. Because the listener lives on the ancestor, descendants added after wiring bubble to it and are handled automatically.
eventDelegation(list, 'click', '.item', handler), then the user clicks an <img> inside <li class="item">:
<img> — event.target is the image. It bubbles up: img → li → ul(list) → …list's listener — event.target.closest('.item') walks from the img upward and returns the <li class="item"> (the nearest match).list.contains(li) is true → it's a real hit.handler.call(li, event, li) runs with this === li. The click on the inner image was correctly attributed to the item..item appended to list needs no new wiring; its clicks bubble to the same listener.event.target directly — misses clicks on children of the target (icon inside a button). Use closest(selector).root — closest can match an ancestor above root; guard with root.contains(match).focus, blur, mouseenter don't bubble; delegate their bubbling twins (focusin/focusout) or use capture.{ capture: true } delegates non-bubbling events (or intercepts before descendants), useful for focus/blur.{ '.edit': onEdit, '.delete': onDelete } map in one listener is how small event-router utilities work.event.stopPropagation interplay — a descendant that stops propagation will hide the event from the delegated listener; worth knowing when delegation "mysteriously" stops firing.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Instead of attaching a listener to every button in a list, event delegation attaches one listener to a common ancestor and lets events bubble up to it. When the event arrives, you check whether it came from (or through) an element matching a selector, and handle it there. It's the classic pattern behind jQuery's .on(parent, selector, handler) and React's synthetic events — cheaper (one listener, not N) and it automatically covers elements added after you wired it up.
Implement eventDelegation(root, eventType, selector, handler). Attach a single listener to root; when an eventType event bubbles up from a descendant matching selector, call handler(event, matchedElement) with this set to the matched element. Return a cleanup function.
function eventDelegation(root, eventType, selector, handler) {
// returns a cleanup function that removes the listener
}
// One listener handles every .item, including ones added later.
const off = eventDelegation(list, 'click', '.item', (e, el) => {
console.log('clicked item', el.dataset.id);
});
off(); // stop listening
closest finds the match — the real event.target may be a child of the selector match (e.g. an icon inside a button); use event.target.closest(selector) to walk up.root (root.contains(matched)).removeEventListener.You'll add one listener to root whose callback runs event.target.closest(selector), and if that match exists inside root, invokes the handler bound to it.
Bubbling means an event fires on the deepest element it hit, then travels up through every ancestor, firing their listeners too. Delegation exploits that: put a single listener on a shared ancestor, and let every descendant's event come to you. When it arrives, event.target is the exact element clicked — but that might be an icon inside the button you care about, so you walk up with closest(selector) to find the nearest matching ancestor. If that match is inside root, it's a hit. This costs one listener regardless of how many items exist, and it keeps working for items added later, because they bubble to the same ancestor.
Stand at the root and watch events float up from below. For each one, ask: "starting at the element that fired it, is there a selector match on the way up to me?" event.target.closest(selector) answers that in one call — it returns the element itself or the nearest ancestor that matches, or null. Guard with root.contains(match) so you don't react to a match that lives above root. On a hit, call the handler with the matched element as both an argument and this.
The naive version compares event.target directly and attaches per element:
function eventDelegationNaive(root, type, selector, handler) {
root.querySelectorAll(selector).forEach((el) =>
el.addEventListener(type, handler), // one listener PER element
);
}
Two problems. It adds N listeners (one per current match), so a 10,000-row table wires 10,000 handlers. And it only sees the elements that existed at wiring time — anything added later has no listener, which breaks the exact use case delegation is for (dynamic lists). A single listener on root plus closest fixes both, and matching by closest also handles the "clicked a child of the target" case that a direct event.target === el check misses.
function eventDelegation(root, eventType, selector, handler) {
const listener = (event) => {
// Walk up from the real target to the nearest selector match.
const match = event.target.closest(selector);
if (match && root.contains(match)) {
handler.call(match, event, match); // `this` = match, + pass it explicitly
}
};
root.addEventListener(eventType, listener);
return () => root.removeEventListener(eventType, listener);
}
module.exports = { eventDelegation };
One listener goes on root. When an event bubbles up, event.target.closest(selector) returns the matched element (the target itself or an ancestor) or null. The root.contains(match) guard ensures the match is genuinely inside our subtree — closest could otherwise return an ancestor above root. On a hit we handler.call(match, event, match) so the handler gets this === match (jQuery-style) and the matched element as a second argument. The returned cleanup removes the exact listener we added. Because the listener lives on the ancestor, descendants added after wiring bubble to it and are handled automatically.
eventDelegation(list, 'click', '.item', handler), then the user clicks an <img> inside <li class="item">:
<img> — event.target is the image. It bubbles up: img → li → ul(list) → …list's listener — event.target.closest('.item') walks from the img upward and returns the <li class="item"> (the nearest match).list.contains(li) is true → it's a real hit.handler.call(li, event, li) runs with this === li. The click on the inner image was correctly attributed to the item..item appended to list needs no new wiring; its clicks bubble to the same listener.event.target directly — misses clicks on children of the target (icon inside a button). Use closest(selector).root — closest can match an ancestor above root; guard with root.contains(match).focus, blur, mouseenter don't bubble; delegate their bubbling twins (focusin/focusout) or use capture.{ capture: true } delegates non-bubbling events (or intercepts before descendants), useful for focus/blur.{ '.edit': onEdit, '.delete': onDelete } map in one listener is how small event-router utilities work.event.stopPropagation interplay — a descendant that stops propagation will hide the event from the delegated listener; worth knowing when delegation "mysteriously" stops firing.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.