The Problem Without Delegation
Imagine a list with 1 000 items. Attaching a click listener to each one wastes memory and is fragile β dynamically added items get no listener at all:
// β Anti-pattern: listener per child
const items = document.querySelectorAll('.list-item');
items.forEach(item => {
item.addEventListener('click', (e) => {
console.log('Clicked:', e.target.textContent);
});
});
// Problem 1: 1000 listener objects in memory
// Problem 2: items added later get NO listener
const newItem = document.createElement('li');
newItem.className = 'list-item';
newItem.textContent = 'New item';
document.querySelector('ul').appendChild(newItem);
// Clicking newItem β nothing happens!
How Event Delegation Works
Because events bubble up the DOM tree, a click on any <li> also fires on the parent <ul>. We attach one listener to the parent and inspect event.target:
// β
Delegation: one listener on parent
const ul = document.querySelector('ul');
ul.addEventListener('click', (e) => {
// e.target = the element that was actually clicked
if (e.target.tagName === 'LI') {
console.log('Clicked item:', e.target.textContent);
}
});
// Works for dynamically added items too!
const newItem = document.createElement('li');
newItem.textContent = 'Dynamically added';
ul.appendChild(newItem);
// Clicking newItem β "Clicked item: Dynamically added" β
Clicked item: Dynamically added
Using .matches() and .closest()
Tag name checks are brittle. Use element.matches(selector) for CSS-selector matching and element.closest(selector) for nested element scenarios:
const list = document.getElementById('todo-list');
list.addEventListener('click', (e) => {
// matches() β CSS selector check on the exact clicked element
if (e.target.matches('.delete-btn')) {
e.target.closest('li').remove();
return;
}
if (e.target.matches('.complete-btn')) {
e.target.closest('li').classList.toggle('done');
return;
}
});
// closest() traverses UP the tree to find the matching ancestor
// Useful when the clicked element is a child of the intended target
// Example: <li><span class="icon">β</span> Task text</li>
list.addEventListener('click', (e) => {
const li = e.target.closest('li'); // works even if user clicks <span>
if (li) {
li.classList.toggle('selected');
}
});
When a list item contains child elements (icons, badges, buttons), e.target may be the icon, not the <li>. e.target.closest('li') safely walks up the tree to the intended element, regardless of which child was clicked.
Practical Example: Todo List with Delegation
// HTML assumed:
// <input id="todo-input" placeholder="Add taskβ¦">
// <button id="add-btn">Add</button>
// <ul id="todo-list"></ul>
const input = document.getElementById('todo-input');
const addBtn = document.getElementById('add-btn');
const todoList = document.getElementById('todo-list');
function addItem(text) {
if (!text.trim()) return;
const li = document.createElement('li');
li.innerHTML = `
<span class="task-text">${text}</span>
<button class="complete-btn">β</button>
<button class="delete-btn">β</button>
`;
todoList.appendChild(li);
input.value = '';
}
addBtn.addEventListener('click', () => addItem(input.value));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') addItem(input.value);
});
// ONE delegated listener handles all current and future items
todoList.addEventListener('click', (e) => {
const li = e.target.closest('li');
if (!li) return;
if (e.target.matches('.delete-btn')) {
li.remove();
} else if (e.target.matches('.complete-btn')) {
li.classList.toggle('done');
}
});
Practical Example: Dynamic Table Actions
const tableBody = document.querySelector('#data-table tbody');
// Delegate on the tbody β handles Edit and Delete for any row
tableBody.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const row = btn.closest('tr');
const id = row.dataset.id;
switch (btn.dataset.action) {
case 'edit':
openEditModal(id);
break;
case 'delete':
if (confirm(`Delete record ${id}?`)) row.remove();
break;
case 'view':
openDetailPanel(id);
break;
}
});
// Adding rows dynamically β no new listeners needed
function addRow(id, name, email) {
tableBody.insertAdjacentHTML('beforeend', `
<tr data-id="${id}">
<td>${id}</td>
<td>${name}</td>
<td>${email}</td>
<td>
<button data-action="edit">Edit</button>
<button data-action="delete">Delete</button>
<button data-action="view">View</button>
</td>
</tr>
`);
}
Direct vs Delegation β Comparison
| Aspect | Direct Listeners | Event Delegation |
|---|---|---|
| Memory | N listeners for N elements | 1 listener regardless of count |
| Dynamic elements | Must re-attach after DOM update | Works automatically |
| Setup complexity | Simple loop | Needs target check |
| stopPropagation risk | None | Child's stopPropagation breaks delegation |
| Best for | Few, static, complex elements | Many or dynamic child elements |
When NOT to Use Delegation
If a child element's listener calls e.stopPropagation(), the event never reaches your delegate listener on the parent. This is a common source of bugs in third-party widget integration. In those cases, attach a direct listener to the child.
Avoid delegation when: events don't bubble (e.g. focus, blur β use focusin/focusout instead); the parent and child are far apart in the tree (performance overhead of bubbling through many nodes); or the child widgets call stopPropagation.
ποΈ Practical Exercise
Build an accordion FAQ list using delegation:
- Create a
<div id="faq">containing several<div class="faq-item">elements, each with an<h3 class="question">and a<p class="answer">(initially hidden with CSS). - Attach a single click listener to
#faq. - When a
.questionis clicked, toggle a CSS classopenon its parent.faq-itemto show/hide the answer. - Close all other items when one is opened (accordion behaviour).
π₯ Challenge Exercise
Build a Kanban-style board with three columns (To Do, In Progress, Done). Use a single delegated listener on the board container. Cards should have "Move Right" and "Move Left" buttons that move them between columns. Cards can be dynamically added via an input in each column. The entire interaction should use fewer than 3 event listeners total.
Summary
π Summary
- Delegation attaches one listener to an ancestor instead of many to children.
- It relies on event bubbling β the event from a child travels up to the parent.
e.targetidentifies which child was clicked; use.matches()for CSS-selector checks.e.target.closest(selector)handles clicks on nested child elements inside the target.- Delegated listeners automatically handle dynamically added children.
- Delegation fails if a child calls
e.stopPropagation(). - Use
focusin/focusoutinstead offocus/blurwhen delegating focus events.
Interview Questions
- What is event delegation and why is it useful?
- How does event delegation work with dynamically added elements?
- What is the difference between
e.targetande.currentTargetin a delegated handler? - When would you choose a direct listener over delegation?
- Why might delegation fail on a page that uses third-party widgets?
Frequently Asked Questions
Not directly. focus and blur do not bubble, so attaching them to a parent won't catch child focus events. Use focusin and focusout instead β they are the bubbling equivalents. Alternatively, you can use the capture phase (addEventListener('focus', handler, true)) which runs top-down before bubbling.
For large or dynamic lists, yes β delegation uses far less memory and avoids the overhead of attaching/removing listeners. For a small fixed set of elements (say, 3 buttons), direct listeners are simpler and the performance difference is negligible. Choose based on readability and the dynamic nature of your elements.
Use data-action attributes on the child elements and check e.target.dataset.action (or e.target.closest('[data-action]').dataset.action for nested elements) inside a switch or if/else chain. This cleanly separates logic per action without cluttering the selector checks.