Event Delegation
Objectives
By the end of this chapter, you should be able to:
- Explain the problem event delegation solves
- Attach one listener to a parent that handles events for its children, including children added later
- Use
event.targetand.closest()to identify which child was actually involved
💡 Why this matters: Real pages add and remove elements constantly, a new list item, a new row, a new card. A listener attached directly to one of those elements only works for elements that already existed when you attached it. Delegation fixes that.
The Problem
Given this HTML:
<ul id="list">
<li class="item">Apples</li>
<li class="item">Bread</li>
</ul>
You could attach a click listener to each .item individually:
document.querySelectorAll(".item").forEach((item) => {
item.addEventListener("click", () => {
console.log("Clicked:", item.textContent);
});
});
This works for the two items that exist right now. But if you later add a third <li class="item"> to the list, it has no listener attached, clicking it does nothing, since the .forEach() loop already finished running before that element existed.
The Fix: Listen on the Parent
Because events bubble (previous lesson), a click on any <li> inside #list also fires on #list itself. Instead of attaching a listener to every item, attach one listener to the parent, and use event.target to figure out which child was actually clicked.
const list = document.getElementById("list");
list.addEventListener("click", (event) => {
const item = event.target.closest(".item");
if (!item) return;
console.log("Clicked:", item.textContent);
});
event.target is the exact element the click happened on, which might be the <li> itself, or something nested inside it. .closest(".item") walks upward from event.target until it finds an element matching .item, so this works correctly even if an <li> contains other markup. The if (!item) return; guard matters too, without it, a click anywhere inside #list (including empty space, if there is any) would try to run the handler with item as null.
Now, adding a new item after the listener was attached still works, since the listener lives on #list, not on the item:
const newItem = document.createElement("li");
newItem.className = "item";
newItem.textContent = "Milk";
list.appendChild(newItem);
newItem.click();
// Clicked: Milk
No new listener was added for newItem. Clicking it still logs correctly, because the click bubbled up to #list, where the one listener has been waiting the whole time.
Try It
Starter HTML for all three exercises:
<ul id="tasks">
<li class="task">Write code</li>
<li class="task">Test code</li>
</ul>
<div id="gallery">
<img class="thumb" src="cat.jpg" alt="Cat">
<img class="thumb" src="dog.jpg" alt="Dog">
</div>
- Attach one click listener to
#tasksthat logs the clicked task’s.textContent, usingevent.target.closest(".task"). - Add a new
<li class="task">to#tasksafter attaching the listener, then click it and confirm the listener still fires. - Attach one click listener to
#gallerythat logs the clicked thumbnail’ssrcattribute, usingevent.target.closest(".thumb").
Recap
- Attaching a listener to every individual element misses any element added later.
- Delegation attaches one listener to a stable parent and relies on bubbling: a click on any current or future child still reaches the parent.
event.targetis the exact element clicked..closest(selector)walks upward from it to find the meaningful element you actually care about, guarding againstnullwhen the click didn’t land on a match.
Next lesson: dispatching your own custom events, for when the built-in ones don’t cover what you need.