Event Delegation
Learn how to handle events efficiently using event delegation in JavaScript.
Event Delegation
In the previous topic we learned that events bubble up through the DOM. Event delegation is a technique that uses bubbling on purpose — instead of adding listeners to individual elements, you add one listener to a parent and let the events bubble up to it.
This sounds simple but it is one of the most powerful and practical patterns in DOM programming.
The Problem It Solves
Imagine you have a list of 100 items and each one needs a click listener.
<ul id="todo-list">
<li class="item">Variables</li>
<li class="item">Functions</li>
<li class="item">Arrays</li>
<!-- 97 more items... -->
</ul>The naive approach — add a listener to every item:
const items = document.querySelectorAll(".item");
items.forEach(item => {
item.addEventListener("click", () => {
console.log(`Clicked: ${item.textContent}`);
});
});This works — but it has two serious problems:
Problem 1 — Performance. 100 event listeners sitting in memory. For large lists this adds up.
Problem 2 — Dynamic elements. If you add a new item to the list after this code runs, the new item has no listener. You would have to manually attach one every time you add an element.
const newItem = document.createElement("li");
newItem.textContent = "Objects";
newItem.classList.add("item");
todoList.appendChild(newItem);
// ❌ This new item has no click listenerEvent delegation solves both problems at once.
The Solution — One Listener on the Parent
Instead of listening on each item, listen on the parent ul. When any item is clicked, the event bubbles up to the ul — and you check event.target to see which item was actually clicked.
const list = document.getElementById("todo-list");
list.addEventListener("click", (e) => {
console.log(`Clicked: ${e.target.textContent}`);
});One listener. Handles all 100 items. And any new item added later is automatically covered — because it is inside the ul and its events will bubble up.
How It Works
The click happens on the li. It bubbles up to the ul. The ul's listener fires — and event.target tells you exactly which li was clicked.
Filtering With event.target
The parent might contain different types of elements. Use event.target to make sure you only react to the right ones.
<ul id="todo-list">
<li class="item">
<span class="text">Variables</span>
<button class="delete-btn">Delete</button>
</li>
<li class="item">
<span class="text">Functions</span>
<button class="delete-btn">Delete</button>
</li>
</ul>const list = document.getElementById("todo-list");
list.addEventListener("click", (e) => {
// Only react to delete buttons
if (e.target.classList.contains("delete-btn")) {
const item = e.target.closest(".item"); // find the parent li
item.remove();
}
// Only react to the text span
if (e.target.classList.contains("text")) {
e.target.parentElement.classList.toggle("done");
}
});One listener handles two different click targets — delete buttons and text spans — cleanly.
closest() — Finding the Right Parent
closest() walks up the DOM tree from the clicked element and returns the first ancestor that matches a CSS selector. It is the perfect companion to event delegation.
list.addEventListener("click", (e) => {
// e.target might be a span or button INSIDE the li
// closest() finds the li no matter what was actually clicked
const item = e.target.closest(".item");
if (item) {
console.log(`Item clicked: ${item.querySelector(".text").textContent}`);
}
});Without closest() — if the user clicks the text inside the li rather than the li itself — e.target would be the <span>, not the <li>. closest(".item") handles this by walking up until it finds the .item element.
// closest() returns null if no match found going up the tree
const item = e.target.closest(".item");
if (!item) return; // clicked somewhere outside items — do nothingDynamic Elements — The Real Power
This is where event delegation truly shines. New elements added to the DOM after the page loads are automatically covered.
const list = document.getElementById("todo-list");
const input = document.getElementById("todo-input");
const addBtn = document.getElementById("add-btn");
// One delegated listener covers ALL items — including future ones
list.addEventListener("click", (e) => {
if (e.target.classList.contains("delete-btn")) {
e.target.closest(".item").remove();
}
if (e.target.classList.contains("text")) {
e.target.closest(".item").classList.toggle("done");
}
});
// Add new items dynamically
addBtn.addEventListener("click", () => {
const text = input.value.trim();
if (!text) return;
const li = document.createElement("li");
li.className = "item";
li.innerHTML = `
<span class="text">${text}</span>
<button class="delete-btn">Delete</button>
`;
list.appendChild(li);
input.value = "";
// ✅ No need to add a listener to the new item — delegation handles it
});Every new item added is immediately interactive — delete and done-toggle both work — without touching the event listener code at all.
When to Use Event Delegation
Not every situation calls for delegation. Here is how to decide:
// Use delegation when:
// 1. Many similar elements need the same behavior
// 2. Elements are added dynamically
// 3. A large list or table needs interaction
// Use direct listeners when:
// 1. Just one or two elements
// 2. Elements are static and never change
// 3. Each element needs completely different behavior| Situation | Use |
|---|---|
| Large list of clickable items | Delegation |
| Dynamically added elements | Delegation |
| One button, one action | Direct listener |
| Static, non-changing elements | Direct listener |
| Table rows that can be added/removed | Delegation |
A Real Example — Data Table With Actions
<table id="users-table">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="users-body">
<tr data-id="1">
<td>Ali Hassan</td>
<td>Admin</td>
<td>
<button class="btn-edit">Edit</button>
<button class="btn-delete">Delete</button>
</td>
</tr>
<tr data-id="2">
<td>Sara Khan</td>
<td>Editor</td>
<td>
<button class="btn-edit">Edit</button>
<button class="btn-delete">Delete</button>
</td>
</tr>
</tbody>
</table>const tableBody = document.getElementById("users-body");
tableBody.addEventListener("click", (e) => {
const row = e.target.closest("tr");
if (!row) return;
const userId = row.dataset.id;
const userName = row.cells[0].textContent;
if (e.target.classList.contains("btn-delete")) {
if (confirm(`Delete ${userName}?`)) {
row.remove();
console.log(`Deleted user ${userId}`);
}
}
if (e.target.classList.contains("btn-edit")) {
const newName = prompt(`Edit name for user ${userId}:`, userName);
if (newName) {
row.cells[0].textContent = newName;
}
}
});
// Adding a new user — automatically works with existing listener
function addUser(id, name, role) {
const row = document.createElement("tr");
row.dataset.id = id;
row.innerHTML = `
<td>${name}</td>
<td>${role}</td>
<td>
<button class="btn-edit">Edit</button>
<button class="btn-delete">Delete</button>
</td>
`;
tableBody.appendChild(row);
}
addUser(3, "Zara Ahmed", "Viewer");
// New row works with edit and delete immediately — no extra listeners neededOne listener on tbody handles edit and delete for every row — existing and future. closest("tr") finds the right row even if the button's inner span was clicked. dataset.id gives you the data attribute directly.
dataset — Reading Data Attributes
element.dataset gives you easy access to all data-* attributes on an element.
<button data-user-id="42" data-action="delete">Delete</button>btn.addEventListener("click", (e) => {
console.log(e.target.dataset.userId); // "42"
console.log(e.target.dataset.action); // "delete"
});data-user-id becomes dataset.userId — the hyphen is converted to camelCase. This is a clean way to store information in the HTML that JavaScript can read during delegation.
Summary
- Event delegation uses bubbling to handle many elements with one listener on their parent
event.targettells you exactly which element triggered the event- Use
closest()to find the nearest matching ancestor — handles clicks on nested elements - Delegated listeners automatically cover dynamically added elements
- Use
datasetto store and read data attributes from HTML elements - Delegation is best for large lists, tables, and any dynamically changing content
- Direct listeners are fine for single static elements with unique behavior