Why put one listener on the parent?
A to-do list with a Delete button on every item seems to need a listener on every button. That works until the list changes. Items added later arrive with buttons that have no listener, so they do nothing, and you end up adding listeners in two places and hoping they match.
Event delegation solves this with bubbling, covered in the events lesson. A click on a Delete button does not stop at the button. It rises through the <li>, then the <ul>, then the rest of the page. Put one listener on the <ul> and it hears clicks on every button inside, whenever that button was created.
The listener then asks one question: which item was this click about? The answer comes from event.target, the element where the click began, and from closest, which walks up from there.
const todos = document.querySelector("#todos");
todos.addEventListener("click", (event) => {
console.log("Clicked inside the list on", event.target.tagName);
});

closest works out which button it came from.How do you find the item that was clicked?
event.target is the innermost element under the pointer. In a real list, that is often a child of the button you care about: an icon, a <span> around the label, an <svg>. Checking event.target.matches(".delete") fails whenever the visitor clicks the label instead of the button's edge, which is most of the time.
el.closest(selector) fixes that. It checks the element itself, then its parent, then its grandparent, and returns the first one that matches the selector, or null if it reaches the top of the page without a match. event.target.closest(".delete") therefore gives you the Delete button whether the click landed on the button or on anything inside it.
From the button, call closest again to reach the whole item: button.closest("li"). That is the element to remove, update or read data from. Try the demo with the Step lens: click a Delete label and watch button hold the button even though the target was the span.
todos.addEventListener("click", (event) => {
const button = event.target.closest(".delete");
if (!button) return; // the click was not on a Delete button
const item = button.closest("li");
item.remove();
});Why do you need the early return?
The parent hears every click inside it, including clicks that miss the buttons. Clicking a task's text, the gap between items or the list's padding all reach the listener. In those cases closest(".delete") returns null, and calling null.closest("li") throws an error.
The guard if (!button) return; ends the function for those clicks without doing anything. It is the one line every delegated listener needs, and the one most often missing from quick examples and AI output. Without it, the code seems fine while you test by clicking buttons, then fails the first time someone clicks near one.
A guard also protects nested lists. Limit the search to the current list by checking todos.contains(button) when lists can sit inside each other, so a click in an inner list is not handled twice.
How do data attributes identify items?
Removing an <li> from the page is often only half the job. The data behind it, an array of tasks or a server record, needs to know which item went. Store an identifier on the element with a data- attribute, such as data-id="3", and read it in the listener.
Every element has a dataset property that exposes its data attributes. Names are converted to camelCase: data-id is dataset.id, and data-bird-name is dataset.birdName. Values are always strings, so convert with Number() if you compare them with numeric ids.
Data attributes also let one listener handle several kinds of button. Give each button a data-action such as "delete" or "done", find the button with closest("[data-action]"), and branch on button.dataset.action.
let tasks = [
{ id: 1, text: "Feed the starter" },
{ id: 2, text: "Shape the loaves" }
];
todos.addEventListener("click", (event) => {
const button = event.target.closest("[data-action]");
if (!button) return;
const item = button.closest("li");
const id = Number(item.dataset.id);
if (button.dataset.action === "delete") {
tasks = tasks.filter((task) => task.id !== id);
item.remove();
}
});What does a full to-do list look like?
Here is a complete small list that adds items from a form and deletes them with one delegated listener. New rows come from a <template>, as in the creating elements lesson. Notice that the add code knows nothing about deleting. It builds the row and appends it; the listener on the list already covers the new button.
That separation is the practical payoff. Each part of the code does one job, and adding a feature such as a Done button means one new branch in the listener and one new button in the template, with no extra wiring for every row.
const form = document.querySelector("#new-task");
const todos = document.querySelector("#todos");
const template = document.querySelector("#row");
let nextId = 1;
form.addEventListener("submit", (event) => {
event.preventDefault();
const row = template.content.cloneNode(true);
const item = row.querySelector("li");
item.dataset.id = String(nextId++);
row.querySelector(".text").textContent = form.elements.task.value;
todos.append(row);
form.reset();
});
todos.addEventListener("click", (event) => {
const button = event.target.closest(".delete");
if (!button) return;
button.closest("li").remove();
});When is delegation better than many listeners?
Use delegation when items are added or removed while the page is open: to-do lists, search results, chat messages, shopping baskets, table rows loaded from a server. It is also a good fit when there are many similar items, hundreds of rows in a table for example, because one listener uses less memory than hundreds and is set up instantly.
Separate listeners are clearer for a handful of fixed controls that each do something different, such as a page header with a menu button, a search box and a theme switch. Delegating those to document saves nothing and makes the code harder to follow, since a reader has to work out which branch handles which control.
A few events do not bubble, so they cannot be delegated directly. focus and blur are the usual ones; use focusin and focusout instead, which do bubble. mouseenter and mouseleave do not bubble either; mouseover and mouseout do.
How do you check and debug a delegated listener?
Open the Events lens and click around the list. Every click inside it shows the same single listener on ul#todos responding, along with the target element. If nothing appears, the listener is attached to the wrong element or was added before the list existed.
When the wrong thing happens, log event.target and the result of closest at the start of the listener. You will usually see one of three things: the target is an inner element you did not expect, the selector passed to closest does not match your HTML, or closest returned null and the guard is missing.
When reading AI-written code, look for listeners added inside a loop over querySelectorAll in a list that can change, for event.target.classList.contains(...) checks that fail on inner elements, and for dataset keys written with hyphens. Each one has a direct fix: one listener on the parent, closest, and camelCase keys.
Field notes
- One listener on a parent hears clicks from every child inside it.
event.target.closest(".delete")finds the button even when you click an icon inside it.- Return early when
closestfinds nothing, because the parent hears clicks that miss the buttons. - Delegation suits lists where items are added or removed while the page is open.
Easy to get wrong
When the button holds an icon or a span, the target is that inner element and matches fails. closest checks the element and its ancestors.
Clicks on text or padding give null from closest, and calling a method on null throws. Return early when nothing matched.
dataset converts data-bird-name to the camelCase key birdName. The hyphenated key is always undefined.
Questions people ask
Does delegation make pages slower?
No. The listener runs for clicks inside the parent, and a closest call is very cheap. It usually saves memory compared with one listener per item.
Can I delegate to document instead of the list?
You can, and some libraries do. Attaching to the nearest stable parent keeps the code easier to read and avoids handling clicks from unrelated parts of the page.
What if a button inside the list calls stopPropagation?
Then the click never reaches the parent and the delegated listener does not run. Avoid stopPropagation in code that sits inside delegated areas.
Does closest work on text nodes?
No, it is an element method. Click events always target an element, so event.target.closest is safe in click listeners.
Should data-id values be numbers?
They are stored as strings. Convert with Number() when you compare them with numeric ids in your data.