Event delegation
One listener for a thousand items — the pattern that makes long lists cheap and dynamic content easy.
You have a list of 200 todo items, and each one has a delete button. The naive approach is to attach a click handler to each delete button — 200 listeners, one per row. It works. But it falls apart the moment you add a new item dynamically: the new row has no handler, because you only set them up at startup.
There is a much better pattern, and it falls out for free from the bubbling we covered last lesson. It is called event delegation, and it is the single most useful event pattern you will learn.
The naive way
Here is the version most beginners write first.
document.querySelectorAll('.todo .delete').forEach((btn) => {
btn.addEventListener('click', () => {
btn.closest('.todo').remove();
});
});It works for the items that exist when the script runs. But three problems show up almost immediately:
- It does not handle items added later. A new todo gets no listener, so its delete button does nothing.
- It is wasteful. Each listener takes a tiny bit of memory; on a list of thousands you can feel it.
- You have to redo the wiring every time you re-render. The page goes stale, you build new HTML, you forget to re-bind one element, you have a silent bug.
The fix is to put the listener somewhere that does not change.
The delegation pattern
Click events bubble. A click on the delete button inside row 47 bubbles all the way up the tree, passing through the row, the list, the section, and the body before reaching the document. So you can listen on any of those ancestors and catch the click as it goes by.
The pattern is: put one listener on the parent, then look at event.target to figure out which child got clicked.
const list = document.querySelector('.todos');
list.addEventListener('click', (event) => {
const deleteBtn = event.target.closest('.delete');
if (!deleteBtn) return;
// not a delete-button click, ignore
const row = deleteBtn.closest('.todo');
row.remove();
});One listener, on the list. It catches every click that bubbles up. The first thing it does is ask: did this click happen on or inside a .delete element? closest('.delete') answers that — it returns the button if so, or null if the click was somewhere else in the list.
If the click was not on a delete button, the handler returns early and does nothing. Otherwise, it finds the row that contains the button and removes it.
A useful picture: instead of stationing one guard at every door, you have one guard at the front of the building. They check who is walking past — if it is someone they care about, they act. If not, they wave them through. One guard, every door covered.
This pattern handles new items for free. Add a new .todo to the list at any time — the listener on the list does not care, it sees the click bubble up just like the others. No re-binding, no setup, no leaks.
closest is the natural pair for delegation. Without it, you would have to traverse event.target by hand every time. With it, "did the click land on something I care about?" is one line.
if (!deleteBtn) return; matter in a delegated handler?Reading data off the clicked element
Once you have the clicked element, the next question is usually: which item was it? The dataset trick from lesson 3 fits in here perfectly.
<ul class="todos"> <li class="todo" data-id="42"><span>Buy milk</span> <button class="delete">x</button></li> <li class="todo" data-id="43"><span>Call mom</span> <button class="delete">x</button></li> </ul>
list.addEventListener('click', (event) => {
const deleteBtn = event.target.closest('.delete');
if (!deleteBtn) return;
const row = deleteBtn.closest('.todo');
const id = Number(row.dataset.id);
fetch('/todos/' + id, { method: 'DELETE' })
.then(() => row.remove());
});The id is right there on the row, in data-id. The handler pulls it off the dataset, sends a request, and removes the row on success. No global lookup. No "which array index is this?" worry.
This pattern scales to any list-of-things UI: comments, search results, contacts, file rows. One delegated listener, one closest to find the row, one dataset to pull the id. Every operation looks the same.
When delegation doesn't fit
Delegation is the right default, but a few events do not bubble — and a few cases call for direct listeners anyway.
The events that do not bubble: focus, blur, mouseenter, mouseleave, load, scroll (in some contexts), and a handful of others. For focus and blur specifically, you can use the bubbling versions focusin and focusout if you really want to delegate. For mouseenter and mouseleave, the bubbling alternatives are mouseover and mouseout, but they fire more often than you usually want.
A general rule: if delegation feels awkward, attach the listener directly. The pattern is a tool, not a religion.
focus and blur do not bubble. To delegate focus events, listen for focusin and focusout instead — those are the bubbling cousins, designed for exactly this case.
focus event, hoping to react when any input gains focus. The handler never fires. Why?event.target is sometimes the icon, sometimes the button text, sometimes the button itself. What is the cleanest way to land on the button reference?