Event delegation is the technique of putting one listener on a container and letting the events of every child reach it on the way up. It is the standard answer to two problems that plague hand-written DOM code: rows that are added to a page after the script has run, and lists that need one handler per item. This page shows the pattern, the two pieces of syntax it needs, and the events it cannot help with.
Why does one listener on a parent work for every child?
Because most events bubble. When you click a button, the browser does not just fire an event on the button — it fires one on the button, and then on every ancestor of that button, finishing at the document. A listener attached to the container receives that bubbled event on its way past, which means a single listener can serve fifty children that all behave the same way.
You can see the journey in the page markup used by these examples:
<ul id="tasks">
<li class="task" data-id="t1">
Write the tests
<button class="done" data-action="done">Done</button>
<button class="delete" data-action="delete">Delete</button>
</li>
<li class="task" data-id="t2">
Ship the feature
<button class="done" data-action="done">Done</button>
<button class="delete" data-action="delete">Delete</button>
</li>
</ul>
<div id="counter" aria-live="polite">2 tasks left</div>
<input id="note" type="text" placeholder="Type here">
The standard delegation pattern has three steps: listen on the container, use event.target.closest(...) to work out which descendant was involved, and return early if there was no match.
const list = document.querySelector("#tasks");
list.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return;
const item = button.closest(".task");
console.log("clicked:", button.dataset.action);
console.log("task id:", item.dataset.id);
});
list.querySelector("button").dispatchEvent(new MouseEvent("click", { bubbles: true }));
The guard on the second line matters. The container also receives clicks that hit the row text, not a button, and closest returns null in that case. Without the guard, button.dataset throws. Always check before you use the result.
Why do listeners on newly added elements never fire?
Here is the bug that pushes people towards delegation in the first place. Attach a click listener directly to each row while the page loads and it works fine — until a row is added later by a filter, a search, or a server response. The new row was never in the page when the script ran, so it never got a listener, and clicking it does nothing at all.
Delegation sidesteps the problem completely. The listener lives on the container, and the container is not replaced, so every row added afterwards is covered from the moment it exists.
const list = document.querySelector("#tasks");
list.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return;
console.log("handled:", button.closest(".task").dataset.id);
});
// A row added AFTER the listener was attached still works
const fresh = document.createElement("li");
fresh.className = "task";
fresh.dataset.id = "t3";
const freshButton = document.createElement("button");
freshButton.dataset.action = "done";
freshButton.textContent = "Done";
fresh.append("Write the docs ", freshButton);
list.append(fresh);
list.querySelector('[data-id="t3"] button')
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
Notice that nothing was bound to fresh or freshButton. The single listener on the list handles the click because the event bubbles up through it.
How do you handle different actions from one listener?
A delegated listener usually has to cope with several kinds of clickable thing. Rather than write one listener per button, put a data-action attribute on each control and switch on its value. The container stays the only place that knows about events; the data attributes say what each control wants to happen.
const list = document.querySelector("#tasks");
list.addEventListener("click", (event) => {
const trigger = event.target.closest("[data-action]");
if (!trigger) return;
const item = trigger.closest(".task");
const action = trigger.dataset.action;
if (action === "done") {
item.classList.add("task--done");
console.log("completed:", item.dataset.id);
} else if (action === "delete") {
item.remove();
console.log("deleted:", item.dataset.id);
}
});
list.querySelector('[data-action="done"]')
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
The data-action attribute and the closest selector are the two halves of the pattern: the attribute tells you what was clicked, and closest tells you which element was clicked at all. Together they let one handler serve a list with any number of button types, including ones you add later.
If a delegated control is a link, remember that clicking it still triggers the browser's default navigation. Call event.preventDefault() in the handler when you want to handle the action yourself, and do it before you branch, so every branch behaves the same way.
When do you use matches() instead of closest?
closest walks up the tree from the event target until it finds a match. matches asks a single question about a single element: does this element itself match the selector? They answer different questions, and mixing them up is a common source of handlers that fire when they should not.
const list = document.querySelector("#tasks");
const row = list.querySelector(".task");
const button = row.querySelector("button");
// matches() asks about this element only
console.log(button.matches("button[data-action='done']")); // true
console.log(button.matches(".task")); // false
// closest() walks up until something matches
console.log(button.closest(".task").dataset.id); // "t1"
console.log(button.closest(".missing")); // null
There is one case where matches is the right tool: when you want the event target itself and nothing else. If a click on a nested icon should not count as a click on the button, test the target with matches and bail out when it does not match.
Which events never bubble?
Delegation depends on bubbling, and a handful of common events do not bubble. focus and blur are the classic pair: they fire on the element itself and stop there, so a delegated focus listener on a form never hears about a field being focused.
The bubbling variants focusin and focusout exist for exactly this reason, and they are what you should use for delegated focus handling. The same pattern applies to mouseenter and mouseleave, which are replaced by the bubbling mouseover and mouseout when you need delegation.
const note = document.querySelector("#note");
// focusin and focusout bubble, so one listener covers every field on the page
document.addEventListener("focusin", (event) => {
console.log("focused:", event.target.id);
});
document.addEventListener("focusout", (event) => {
console.log("blurred:", event.target.id);
});
note.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
note.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
One more limit worth knowing: the submit event does bubble, but the click event on a submit button does not reach a form listener when the button is inside the form. Listen for submit on the form rather than for click on the button.
Is delegation always the right choice?
Delegation is not free. Every delegated event travels further up the tree before your code sees it, and your handler runs for clicks that had nothing to do with your feature — a delegated handler on document sees every click on the page. For a page with hundreds of items, one listener on the container is clearly better than hundreds of listeners on the items. For a page with three buttons, attaching a listener to each is clearer and costs nothing.
A reasonable rule: delegate when the number of children is unknown or changes, and attach directly when it is fixed and small. When you do attach directly, keep the reference to each handler so you can remove it later.
One more detail catches people out: if your handler removes the clicked element, event.target still points at the detached node afterwards. Reading its attributes is fine, but any layout measurement on it returns zeros, because it is no longer part of the document.
const list = document.querySelector("#tasks");
const counter = document.querySelector("#counter");
// One listener, many rows, and a live count that updates with it
list.addEventListener("click", (event) => {
const trigger = event.target.closest("[data-action]");
if (!trigger) return;
const item = trigger.closest(".task");
if (trigger.dataset.action === "done") {
item.classList.toggle("task--done");
} else {
item.remove();
}
const left = list.querySelectorAll(".task:not(.task--done)").length;
counter.textContent = `${left} task${left === 1 ? "" : "s"} left`;
});
list.querySelector('[data-action="done"]')
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
console.log(counter.textContent); // "1 task left"
The delegation pattern reference
container.addEventListener("click", handler)— one listener for every descendant.event.target.closest(selector)— find the ancestor of the click that matches.data-action="name"— say what a control does; read it withelement.dataset.action.element.matches(selector)— test the target itself, without walking up.focusinandfocusout— the bubbling versions of focus and blur.container.addEventListener("submit", handler)— catch form submissions on the form, not the button.
Try it yourself: build a list of ten rows in your editor, each with a button, then attach one click listener to the list that prints the row's data-id. Now add a eleventh row at runtime and confirm its button still works without any extra code. Next, try attaching a listener to each button instead, add a twelfth row, and watch that one button stay dead. That difference is the whole argument for delegation.