Event delegation is one of the most useful techniques in JavaScript when we work with lists, tables, menus, dropdowns, or any UI that contains many interactive elements.
Instead of attaching a separate event listener to every child element, we attach one listener to a common parent and let the event bubble up. Then we figure out which child triggered the event and handle it from there.
This approach gives us cleaner code, better performance, and much easier support for dynamic DOM updates.
What is event delegation?
Event delegation is a pattern where:
- A child element triggers an event.
- The event bubbles up through its ancestors.
- A parent listener catches that event.
- We inspect the event target and decide what logic to run.
Basic example
const list = document.getElementById('todo-list');
list.addEventListener('click', (event) => {
if (event.target.matches('button.delete-btn')) {
const item = event.target.closest('li');
item.remove();
}
});In this example, we do not attach a click handler to every delete button. One listener on the list handles all current and future delete buttons.
Why does it work?
It works because of event bubbling.
When an event happens on an element, the browser processes it in phases:
- Capturing phase
- Target phase
- Bubbling phase
Most event delegation examples rely on the bubbling phase.
Example of bubbling
<div id="parent">
<button id="child">Click me</button>
</div>document.getElementById('parent').addEventListener('click', () => {
console.log('parent clicked');
});
document.getElementById('child').addEventListener('click', () => {
console.log('child clicked');
});When the button is clicked, the console logs:
child clicked
parent clickedThe click starts at the button and then bubbles to the parent. That bubbling behavior is exactly what event delegation uses.
Event delegation vs direct event binding
Direct binding
const buttons = document.querySelectorAll('.delete-btn');
buttons.forEach((button) => {
button.addEventListener('click', () => {
console.log('delete item');
});
});This works, but it has drawbacks:
- It creates many listeners.
- Newly added buttons do not get listeners automatically.
- It makes setup code more repetitive.
Delegated binding
document.getElementById('todo-list').addEventListener('click', (event) => {
if (event.target.matches('.delete-btn')) {
console.log('delete item');
}
});This approach:
- uses one listener
- works for dynamically added elements
- reduces repetitive setup logic
Real-world example: todo app
Imagine we render todo items dynamically.
<ul id="todo-list">
<li>
<span>Learn closures</span>
<button class="delete-btn">Delete</button>
</li>
<li>
<span>Practice DOM</span>
<button class="delete-btn">Delete</button>
</li>
</ul>Without delegation
function bindDeleteButtons() {
document.querySelectorAll('.delete-btn').forEach((button) => {
button.addEventListener('click', () => {
button.closest('li').remove();
});
});
}
bindDeleteButtons();If a new item is added later, we must call bindDeleteButtons() again or manually attach a new listener.
With delegation
const todoList = document.getElementById('todo-list');
todoList.addEventListener('click', (event) => {
const deleteButton = event.target.closest('.delete-btn');
if (!deleteButton) return;
deleteButton.closest('li').remove();
});Now every existing and future delete button works automatically.
Why closest() is often better than matches()
Sometimes the user clicks an icon or a span inside a button.
<button class="delete-btn">
<span>Delete</span>
</button>If you only check event.target.matches('.delete-btn'), it may fail because the actual clicked element is the span, not the button.
A better solution is:
const button = event.target.closest('.delete-btn');
if (button) {
console.log('delete clicked');
}closest() walks up the DOM tree from the clicked node until it finds a matching ancestor.
Common use cases for event delegation
Event delegation is especially useful for:
- lists with many items
- tables with row actions
- dropdown menus
- tabs
- accordions
- chat messages
- notification panels
- infinite scroll UIs
- dynamic content inserted by JavaScript
Example: table row actions
<table id="users-table">
<tbody>
<tr data-user-id="101">
<td>Ram</td>
<td><button class="edit-btn">Edit</button></td>
</tr>
<tr data-user-id="102">
<td>Sam</td>
<td><button class="edit-btn">Edit</button></td>
</tr>
</tbody>
</table>const table = document.getElementById('users-table');
table.addEventListener('click', (event) => {
const editButton = event.target.closest('.edit-btn');
if (!editButton) return;
const row = editButton.closest('tr');
const userId = row.dataset.userId;
console.log(`Edit user ${userId}`);
});This scales much better than adding an event handler to every row button.
Performance benefits
Event delegation can improve performance in large UIs because:
- fewer listeners are created
- less setup code runs during render
- memory usage can be lower
- dynamic updates need less rebinding work
That said, the main value is often maintainability, not just raw speed. Modern browsers handle many listeners well, but delegated logic is still easier to manage in large interfaces.
Dynamic DOM support
One of the biggest wins with delegation is support for elements added later.
const list = document.getElementById('todo-list');
list.addEventListener('click', (event) => {
const button = event.target.closest('.delete-btn');
if (!button) return;
button.closest('li').remove();
});
function addItem(text) {
const li = document.createElement('li');
li.innerHTML = `
<span>${text}</span>
<button class="delete-btn">Delete</button>
`;
list.appendChild(li);
}No extra listener setup is needed when addItem() runs.
Events that do not bubble normally
Not every event bubbles in the way you expect.
Examples:
focusblurmouseentermouseleave
If you need delegation for focus-related behavior, use focusin and focusout because they bubble.
form.addEventListener('focusin', (event) => {
if (event.target.matches('input')) {
event.target.classList.add('is-active');
}
});For hover interactions, mouseover and mouseout are usually more delegation-friendly than mouseenter and mouseleave.
Best practices
1. Attach the listener to the closest stable parent
Do not always attach listeners to document unless you really need global delegation.
Better:
menu.addEventListener('click', handleMenuClick);Less ideal:
document.addEventListener('click', handleEverything);Using the nearest stable container keeps the logic more focused and avoids unnecessary event processing.
2. Guard early
container.addEventListener('click', (event) => {
const target = event.target.closest('[data-action]');
if (!target) return;
// handle action
});This makes the handler easier to read and avoids deeply nested if blocks.
3. Prefer data attributes for actions
<button data-action="edit">Edit</button>
<button data-action="delete">Delete</button>container.addEventListener('click', (event) => {
const button = event.target.closest('[data-action]');
if (!button) return;
const action = button.dataset.action;
if (action === 'edit') {
console.log('edit item');
}
if (action === 'delete') {
console.log('delete item');
}
});This is often more maintainable than depending only on CSS classes.
Common mistakes
1. Using event.target too literally
The user may click a nested element inside the intended target. Use closest() when appropriate.
2. Delegating from a parent that is too broad
Attaching all UI logic to document can turn one handler into a giant conditional block.
3. Forgetting that some events do not bubble
If your delegated listener never fires, verify whether that event supports bubbling.
4. Not checking whether the matched element belongs to the right container
In nested UIs, it is useful to confirm the matched element is really inside the delegated root.
container.addEventListener('click', (event) => {
const button = event.target.closest('.action-btn');
if (!button || !container.contains(button)) return;
});Example: one handler for multiple actions
<div id="product-list">
<button data-action="save" data-id="1">Save</button>
<button data-action="share" data-id="1">Share</button>
<button data-action="delete" data-id="1">Delete</button>
</div>const productList = document.getElementById('product-list');
productList.addEventListener('click', (event) => {
const button = event.target.closest('[data-action]');
if (!button) return;
const { action, id } = button.dataset;
switch (action) {
case 'save':
console.log(`Save product ${id}`);
break;
case 'share':
console.log(`Share product ${id}`);
break;
case 'delete':
console.log(`Delete product ${id}`);
break;
default:
break;
}
});This is a clean and scalable way to manage multiple interactions inside one container.
When not to use event delegation
Event delegation is powerful, but it is not always the best choice.
You may avoid it when:
- only one or two elements need listeners
- the event does not bubble naturally
- a component library already manages event handling cleanly
- the delegated handler becomes too complex and harder to understand
Good engineering is not about using one pattern everywhere. It is about using the right pattern where it actually improves the code.
Final thoughts
Event delegation is a core JavaScript technique that helps you write code that is scalable, efficient, and easier to maintain.
The key idea is simple: listen high, act low.
Once you understand event bubbling and start using closest() carefully, event delegation becomes one of the most practical tools in day-to-day frontend development.