Event Delegation
Event delegation is a pattern where you attach one listener to a shared ancestor instead of one listener per child. It works because most DOM events bubble: a click on a deeply nested element travels up through its parents, so a single handler on the container can react to clicks on any descendant. The result is less memory, simpler setup, and — crucially — handlers that automatically cover children added after the listener was registered.
Why delegate
Imagine a list of 500 rows, each with a delete button. Binding a separate click listener to every button means 500 functions, 500 closures, and 500 entries the browser must track. Worse, if you later append a new row, its button has no listener until you remember to wire one up.
Delegation solves both problems at once. You bind a single listener to the parent <ul>, and inside it you inspect event.target to figure out which child was actually clicked. Add, remove, or replace children freely — the parent listener keeps working with zero extra bookkeeping.
| Approach | Listeners for N items | Works for dynamic children? | Cleanup |
|---|---|---|---|
| One listener per child | N | No — must rebind on add | Remove N listeners |
| Delegation (one on parent) | 1 | Yes — automatic | Remove 1 listener |
The core pattern: event.target and closest
When an event bubbles to the parent, two properties matter. event.currentTarget is the element the listener is attached to (the parent). event.target is the element where the event actually originated (the clicked child).
The naive version checks event.target directly, but that breaks the moment a button contains other markup — clicking the icon inside a button makes target the icon, not the button. The robust fix is Element.closest(selector), which walks up from target and returns the nearest matching ancestor (or itself), or null if none matches.
const list = document.querySelector("#list");
list.addEventListener("click", (event) => {
const button = event.target.closest("button.delete");
if (!button) return; // click was outside any delete button — ignore it
const row = button.closest("li");
console.log("delete row:", row.dataset.id);
row.remove();
});
Output:
delete row: 42
Always guard with
if (!closestMatch) return;. Without it, clicks on empty space inside the container throw because you try to read properties ofnull.
A delegated to-do list
The demo below wires the entire list — toggling, deleting, and even items added at runtime — through a single listener on the <ul>. Notice that the “Add” button creates new <li> elements that immediately respond, with no new listeners attached.
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: system-ui, sans-serif; padding: 24px; }
ul { list-style: none; padding: 0; max-width: 360px; }
li { display: flex; align-items: center; gap: 8px; padding: 8px;
border-bottom: 1px solid #ddd; }
li.done span { text-decoration: line-through; color: #999; }
li span { flex: 1; cursor: pointer; }
.delete { margin-left: auto; cursor: pointer; }
form { display: flex; gap: 8px; margin-bottom: 12px; }
input { flex: 1; padding: 6px; }
button { padding: 6px 12px; cursor: pointer; }
</style>
</head>
<body>
<form id="add-form">
<input id="new-task" placeholder="Add a task..." />
<button type="submit">Add</button>
</form>
<ul id="todos">
<li data-id="1"><span>Learn delegation</span><button class="delete">x</button></li>
<li data-id="2"><span>Ship the feature</span><button class="delete">x</button></li>
</ul>
<script>
const todos = document.querySelector("#todos");
let nextId = 3;
// ONE listener handles toggle + delete for every current and future item.
todos.addEventListener("click", (event) => {
if (event.target.closest(".delete")) {
event.target.closest("li").remove();
return;
}
const span = event.target.closest("span");
if (span) span.parentElement.classList.toggle("done");
});
document.querySelector("#add-form").addEventListener("submit", (event) => {
event.preventDefault();
const input = document.querySelector("#new-task");
const text = input.value.trim();
if (!text) return;
const li = document.createElement("li");
li.dataset.id = String(nextId++);
li.innerHTML = `<span></span><button class="delete">x</button>`;
li.querySelector("span").textContent = text; // safe: avoids HTML injection
todos.appendChild(li);
input.value = "";
});
</script>
</body>
</html>
Reading data from the matched element
Because the handler is generic, you usually need to know which item was acted on. The cleanest channel is a data-* attribute read through the dataset API, which keeps your logic out of class names and text content.
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: system-ui, sans-serif; padding: 24px; }
button { font-size: 1rem; padding: 10px 16px; margin: 4px; cursor: pointer; }
#out { margin-top: 16px; font-family: monospace; }
</style>
</head>
<body>
<div id="toolbar">
<button data-action="save">Save</button>
<button data-action="copy">Copy</button>
<button data-action="delete">Delete</button>
</div>
<div id="out">Click a button.</div>
<script>
const handlers = {
save: () => "Saved!",
copy: () => "Copied to clipboard.",
delete: () => "Deleted.",
};
document.querySelector("#toolbar").addEventListener("click", (event) => {
const btn = event.target.closest("button[data-action]");
if (!btn) return;
const run = handlers[btn.dataset.action];
document.querySelector("#out").textContent = run ? run() : "Unknown action";
});
</script>
</body>
</html>
Pitfalls
Delegation is powerful but has sharp edges worth knowing.
- Non-bubbling events. A few events do not bubble — notably
focus,blur,mouseenter, andmouseleave. Delegate with their bubbling counterparts instead:focusin/focusout, andmouseover/mouseout. event.targetvscurrentTarget. Usetarget(orclosestfrom it) to find what was hit; usecurrentTargetfor the parent. Mixing them up is the most common delegation bug.- Over-broad containers. Delegating on
documentfor everything makes a single function responsible for the whole page and runs on every click. Scope to the smallest sensible ancestor. stopPropagationinside children. If a child handler callsevent.stopPropagation(), the event never reaches your delegated parent listener. Avoid stopping propagation unless you truly need to.
Best Practices
- Attach the listener to the nearest stable ancestor, not
document, to limit how often the handler runs. - Use
event.target.closest(selector)rather than checkingevent.targetdirectly so clicks on nested markup still match. - Always bail early with
if (!match) return;before touching the matched element. - Carry item identity in
data-*attributes and read it viaelement.dataset, keeping logic decoupled from text and classes. - For non-bubbling events, delegate with the bubbling alternative (
focusin,mouseover) or fall back to direct listeners. - Prefer delegation for lists, tables, and any UI whose children change at runtime; it removes an entire class of rebinding bugs.