How Event Delegation Works in JavaScript
Event delegation works because most DOM events bubble. A click can start on a nested span, move through its button, continue through the list item, and finally reach the parent list. If the parent has one listener, that listener can handle clicks for every child, including children added later.
That is the useful part. The trap is thinking delegation means "put a listener on the parent and trust event.target." Real delegated handlers need a target check, a guard for nested markup, and often closest().
Contents
- The naive version
- The rule that makes delegation work
- target vs currentTarget
- Use closest for nested clicks
- Why delegation handles new elements
- When not to use delegation
- A safer delegation helper
- Key Takeaways
The naive version
Start with a list of buttons:
<ul id="todo-list">
<li class="todo-item">
<button data-action="complete">
<span>Finish draft</span>
</button>
</li>
<li class="todo-item">
<button data-action="complete">
<span>Review notes</span>
</button>
</li>
</ul>
The direct approach attaches a listener to every button:
const buttons = document.querySelectorAll("[data-action='complete']");
buttons.forEach((button) => {
button.addEventListener("click", () => {
button.closest(".todo-item")?.classList.add("is-complete");
});
});
That is fine for a tiny static list. It gets irritating when the list is rendered from data, filtered, replaced, or appended to later. Any new button needs a new listener.
const list = document.querySelector("#todo-list");
const item = document.createElement("li");
item.className = "todo-item";
item.innerHTML = `
<button data-action="complete">
<span>Publish lesson</span>
</button>
`;
list?.append(item);
The new button was not in the original querySelectorAll result, so it did not get a listener. The page looks right and the interaction quietly does nothing. A classic frontend bug: technically polite, completely useless.
The rule that makes delegation work
Most DOM events bubble. For a click, the browser starts at the element that was clicked, then walks upward through its ancestors.
<ul id="todo-list">
<li class="todo-item">
<button data-action="complete">
<span>Finish draft</span>
</button>
</li>
</ul>
If the user clicks the span, the event path is roughly:
span
button
li.todo-item
ul#todo-list
body
html
document
That means the parent list can hear clicks that began inside its children.
const list = document.querySelector("#todo-list");
list?.addEventListener("click", (event) => {
console.log("The list heard a click");
});
The listener is attached to the ul, but it runs when a descendant is clicked because the event bubbles up to the ul.
Delegation uses that behavior deliberately:
- Attach one listener to a stable parent.
- Inspect where the event started.
- Act only if the event came from something you care about.
target vs currentTarget
Delegated handlers rely on two event properties that are easy to mix up.
| Property | Meaning in a delegated handler |
|---|---|
event.target | The element where the event started |
event.currentTarget | The element whose listener is currently running |
In this handler, currentTarget is always the list because the listener is attached to the list. target changes based on what the user clicked.
const list = document.querySelector("#todo-list");
list?.addEventListener("click", (event) => {
console.log("target:", event.target);
console.log("currentTarget:", event.currentTarget);
});
If the user clicks a nested span, event.target may be that span, not the button and not the li. That is why this version is too fragile:
list?.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof HTMLButtonElement)) {
return;
}
target.closest(".todo-item")?.classList.add("is-complete");
});
Clicking directly on the button works. Clicking the text inside the button may fail because the target is the nested span.
Use closest for nested clicks
The more reliable pattern is to start from event.target, make sure it is an Element, then climb to the actionable element with closest().
const list = document.querySelector("#todo-list");
list?.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const button = target.closest("[data-action='complete']");
if (!button) {
return;
}
const item = button.closest(".todo-item");
item?.classList.add("is-complete");
});
This handles all of these clicks:
- The button itself.
- A
spaninside the button. - An icon inside the button.
- Any future nested markup inside the same button.
closest() walks upward from the clicked element until it finds the nearest ancestor that matches the selector. If it cannot find one, it returns null.
That null result is important. The parent listener also runs when the user clicks padding, whitespace, or some unrelated child inside the list. The early return is not ceremony. It is what keeps the handler honest.
For more selector debugging, see why querySelector returns null.
Why delegation handles new elements
Delegation works for new children because the listener does not live on the children. It lives on the parent.
const list = document.querySelector("#todo-list");
list?.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const button = target.closest("[data-action='remove']");
if (!button) {
return;
}
button.closest(".todo-item")?.remove();
});
const item = document.createElement("li");
item.className = "todo-item";
item.innerHTML = `
New task
<button data-action="remove">
<span>Remove</span>
</button>
`;
list?.append(item);
The new button never received its own listener. It does not need one. When it is clicked, the click still bubbles up to #todo-list, and the existing parent listener handles it.
That is the main reason delegation is common in lists, menus, tables, tab groups, dropdowns, and any UI that renders repeated controls from data.
When not to use delegation
Delegation is useful, not sacred. Do not force it into every event.
Use direct listeners when:
- There is only one stable element.
- The event does not bubble in the way you need.
- The handler truly belongs to one component boundary.
- Delegation would make the target checks harder to understand than the direct listener.
For example, a form submit handler usually belongs on the form:
const form = document.querySelector("#signup-form");
form?.addEventListener("submit", (event) => {
event.preventDefault();
console.log("Submit the form data");
});
One element, one behavior, one listener. No need to make the parent page inspect every submitted form unless that is actually the design.
Also be careful with events that do not bubble, or events whose bubbling behavior has a more specific replacement. focus and blur do not bubble in the same way as click; focusin and focusout are often better for delegated focus handling.
A safer delegation helper
If you write the same target guard repeatedly, a small helper can keep the pattern consistent.
function delegate(
parent: Element,
eventName: string,
selector: string,
handler: (matchedElement: Element, event: Event) => void,
) {
parent.addEventListener(eventName, (event) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const matchedElement = target.closest(selector);
if (!matchedElement || !parent.contains(matchedElement)) {
return;
}
handler(matchedElement, event);
});
}
Usage:
const list = document.querySelector("#todo-list");
if (list) {
delegate(list, "click", "[data-action='remove']", (button) => {
button.closest(".todo-item")?.remove();
});
}
The parent.contains(matchedElement) check prevents a subtle edge case where closest() finds a matching element outside the parent boundary. That can happen with nested structures and broad selectors. It is rare, but cheap to guard against.
For guided practice, try the event.target exercise and the delegated removal exercise.
Key Takeaways
- Event delegation works because bubbling lets a parent hear events that start inside its descendants.
- In a delegated handler,
event.targetis where the event began andevent.currentTargetis where the listener lives. - Use
closest()instead of assuming the direct target is the actionable element. - Delegation is strongest for repeated or dynamic children, especially lists and menus.
- Use direct listeners when the behavior belongs to one stable element and delegation would make the code less clear.