22Implement event delegation for a dynamic list
Problem
A to-do list adds and removes <li class="item"> elements all the time. Attaching a click listener to every item is slow and easy to get wrong, because new items need listeners too.
Implement delegate(root, eventType, selector, handler). It adds one listener on root. When an event happens on an element inside root that matches selector (or on anything inside such an element), it calls handler(event, matchedElement) with this set to the matched element. It returns a function that removes the listener.
const off = delegate(list, "click", ".item", (event, item) => {
console.log("clicked", item.dataset.id);
});
list.insertAdjacentHTML("beforeend", '<li class="item" data-id="3">New</li>');
// Clicking the new item works without adding a listener.
off(); // stop listeningRequirements
- Items added after
delegatewas called must work. - Clicking a child of an item, such as a
<span>inside the<li>, counts as clicking the item. - Ignore events that do not come from a matching element.
- Ignore matches outside
root, even if an ancestor ofrootmatches the selector.
Try it yourself
Write your solution, run it, then submit to check it against the tests.
Video walkthroughs
Stuck? Watch someone solve it, then try again on your own.
Step by step
Build the solution one idea at a time.
- 1
Listen once, on the container
Events bubble: a click on an
<li>also fires on its<ul>and every ancestor. One listener onroottherefore sees clicks on every current and future item. - 2
Find the matching element
event.targetis the innermost element that was clicked, perhaps a<span>inside the item.event.target.closest(selector)walks up from there and returns the nearest ancestor (or the target itself) that matches. - 3
Make sure the match is inside root
closestkeeps walking pastroot. Ifrootitself sits inside an element that matches, a click on a non-item would find that outer element. Checkroot.contains(match)and ignore anything outside. - 4
Call the handler with the right this
handler.call(match, event, match)gives the handler both the event and the matched element, and setsthislike a normal listener on that element would. - 5
Return a cleanup function
Keep a reference to the listener and return
() => root.removeEventListener(eventType, listener). Callers, including React effects, need a way to unsubscribe.
Approach
Attach one listener to the container and use closest to translate the raw event target into the item the user meant, ignoring matches outside the container.
Solution
function delegate(root, eventType, selector, handler) {
function listener(event) {
const target = event.target instanceof Element ? event.target : event.target.parentElement;
const match = target?.closest(selector);
if (match && root.contains(match)) {
handler.call(match, event, match);
}
}
root.addEventListener(eventType, listener);
return () => root.removeEventListener(eventType, listener);
}
// Try it: press Run, then click the items in the preview.
const list = document.getElementById("root");
list.innerHTML = '<ul><li class="item" data-id="1"><b>Buy milk</b></li><li class="item" data-id="2">Call mum</li></ul>';
delegate(list, "click", ".item", (event, item) => {
item.style.textDecoration = "line-through";
});Complexity
Time: O(d) per event, where d is the distance from the target up to root. Space: O(1): one listener, whatever the number of items.
Edge cases
- Items added later work, because nothing is attached to the items.
- A click on a nested element finds its item through
closest. - A click on empty space inside
rootcalls nothing. - An ancestor of
rootthat matches the selector is ignored. - If the target is a text node, start from its parent element.
Follow-up questions
Answer each one out loud first, then reveal the model answer.