Event Delegation
One listener on a parent handles events from all current and future children.
1. Introduction
Event delegation is a pattern that uses bubbling deliberately. Instead of attaching a listener to every child element, you attach one listener to their common parent. When a child is clicked, the event bubbles up and the parent's listener handles it — inspecting e.target to decide what to do.
This pattern is essential for two scenarios: lists with many items (efficiency) and lists where items are added dynamically (correctness — you cannot attach listeners to elements that don't exist yet).
2. Theory
2.1 The problem without delegation
// Without delegation — attach to every item
const items = document.querySelectorAll('.todo-item');
items.forEach(item => {
item.addEventListener('click', () => {
item.classList.toggle('done');
});
});
// Problem 1: 1000 items = 1000 listeners (memory intensive)
// Problem 2: items added AFTER this code runs have NO listener!
2.2 Event delegation — the solution
// With delegation — ONE listener on the parent
const list = document.querySelector('.todo-list');
list.addEventListener('click', e => {
// e.target is the element that was actually clicked
const item = e.target.closest('.todo-item');
if (!item) return; // clicked something that isn't a todo item
item.classList.toggle('done');
});
// Works for all items — including ones added later!
2.3 Why closest() is the key
Each todo item might contain nested elements (a checkbox, a span, a delete button). When the user clicks the span inside the item, e.target is the span — not the item. closest() walks up the DOM from e.target until it finds a matching ancestor.
// HTML structure:
// <ul class="todo-list"> <-- listener is here
// <li class="todo-item">
// <input type="checkbox">
// <span>Buy groceries</span> <-- user clicks this span
// <button class="delete">X</button>
// </li>
// </ul>
list.addEventListener('click', e => {
// e.target might be: input, span, button, or li
const item = e.target.closest('.todo-item'); // always finds the li
const del = e.target.closest('.delete'); // only truthy for delete btn
if (!item) return; // click outside any item
if (del) {
item.remove(); // delete this item
} else {
item.classList.toggle('done'); // toggle completion
}
});
2.4 Handling multiple actions with data attributes
// Give each button a data-action attribute
// <button data-action="edit">Edit</button>
// <button data-action="delete">Delete</button>
// <button data-action="share">Share</button>
container.addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const card = btn.closest('.card');
if (action === 'edit') openEditor(card);
if (action === 'delete') card.remove();
if (action === 'share') shareCard(card);
});
2.5 Handling dynamically added elements
// Delegation works automatically for elements added later
const list = document.querySelector('#task-list');
// Listener added ONCE at start
list.addEventListener('click', e => {
const item = e.target.closest('.task');
if (item) item.classList.toggle('done');
});
// Add tasks at any time — they're automatically handled
function addTask(text) {
const li = document.createElement('li');
li.className = 'task';
li.textContent = text;
list.appendChild(li); // instant — no need to re-attach listeners
}
addTask('Learn JavaScript');
addTask('Build a project');
setTimeout(() => addTask('Deploy to GitHub'), 2000); // works too
2.6 The guard pattern — checking containment
// Always guard against clicks outside intended targets
parent.addEventListener('click', e => {
// Method 1: closest() returns null if not found
const target = e.target.closest('.item');
if (!target) return; // early return is cleaner than wrapping in if
// Method 2: matches() — check if e.target itself matches
if (!e.target.matches('.btn')) return;
// Method 3: contains() — check if parent contains the target
if (!list.contains(e.target)) return;
// Proceed safely
handleItem(target);
});
2.7 When NOT to use delegation
// Delegation is overkill for static, one-off elements
// Just use direct listeners here:
document.querySelector('#submit-btn').addEventListener('click', handleSubmit);
document.querySelector('#cancel-btn').addEventListener('click', handleCancel);
// Delegation shines for:
// - Lists with many items (10+)
// - Lists where items are added/removed dynamically
// - Multiple action types within repeated card/item components
2.8 Tabbed interface with delegation
const tabs = document.querySelector('.tab-bar');
const panels = document.querySelectorAll('.tab-panel');
tabs.addEventListener('click', e => {
const tab = e.target.closest('[data-tab]');
if (!tab) return;
const target = tab.dataset.tab; // e.g. "about"
// Deactivate all
tabs.querySelectorAll('[data-tab]').forEach(t => t.classList.remove('active'));
panels.forEach(p => p.classList.remove('active'));
// Activate selected
tab.classList.add('active');
document.querySelector(`#panel-${target}`).classList.add('active');
});
3. Real World Example
// Shopping cart — delegation handles edit, delete, quantity change
const cart = document.querySelector('#cart-items');
cart.addEventListener('click', e => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const row = btn.closest('.cart-row');
const itemId = row.dataset.id;
const action = btn.dataset.action;
if (action === 'increase') {
updateQuantity(itemId, +1);
} else if (action === 'decrease') {
updateQuantity(itemId, -1);
} else if (action === 'remove') {
removeItem(itemId);
row.remove();
}
});
cart.addEventListener('change', e => {
const input = e.target.closest('input[data-field="qty"]');
if (!input) return;
const row = input.closest('.cart-row');
const itemId = row.dataset.id;
const qty = parseInt(input.value, 10);
if (qty < 1) { input.value = 1; return; }
setQuantity(itemId, qty);
});
// Adding a new item to cart later — no extra listener setup needed
function addToCart(product) {
const row = createCartRow(product); // builds the .cart-row element
cart.appendChild(row); // parent listener handles it automatically
}
4. Code Example
<div>
<button id="add-task">Add Task</button>
<ul id="task-list"></ul>
</div>
<script>
const list = document.querySelector('#task-list');
const addBtn = document.querySelector('#add-task');
let taskCount = 0;
// ONE listener handles ALL tasks — including future ones
list.addEventListener('click', e => {
const toggleBtn = e.target.closest('[data-action="toggle"]');
const deleteBtn = e.target.closest('[data-action="delete"]');
if (toggleBtn) {
const item = toggleBtn.closest('.task-item');
item.classList.toggle('done');
toggleBtn.textContent = item.classList.contains('done') ? 'Undo' : 'Done';
}
if (deleteBtn) {
deleteBtn.closest('.task-item').remove();
}
});
function createTask(text) {
const li = document.createElement('li');
li.className = 'task-item';
li.innerHTML = `
<span>${text}</span>
<button data-action="toggle">Done</button>
<button data-action="delete">Delete</button>
`;
return li;
}
addBtn.addEventListener('click', () => {
taskCount++;
list.appendChild(createTask(`Task ${taskCount}`));
});
</script>
5. Code Breakdown
Single listener on the list
The listener is attached once to #task-list at page load. Every task added later — whether immediately or after a timeout — is automatically covered because the click bubbles up to the list element.
data-action routing
Each button gets a data-action attribute. The delegation handler reads this attribute to decide what to do, keeping all task logic in one place rather than split across many closures.
closest() for reliable targeting
The "Done" button contains only text, so e.target will be the button itself. But closest('.task-item') reliably walks up to the containing list item regardless of which child element was clicked.
innerHTML with static template strings
The task text is inserted via innerHTML here for brevity. In real apps, use textContent for user-supplied text to prevent XSS — replace <span>${text}</span> with a createElement and textContent assignment.
6. Common Mistakes
Mistake 1 — Forgetting the guard check
// Bug — e.target may be the container itself or any descendant
list.addEventListener('click', e => {
e.target.classList.toggle('done'); // crashes if e.target is the list ul itself
});
// Fix — guard with closest
list.addEventListener('click', e => {
const item = e.target.closest('.task-item');
if (!item) return;
item.classList.toggle('done');
});
Mistake 2 — Attaching delegation too high
// Avoid — attaching to document for everything
document.addEventListener('click', e => {
if (e.target.matches('.btn')) doThing();
});
// This fires for EVERY click on the page. Use the closest parent instead.
// Better
document.querySelector('.toolbar').addEventListener('click', e => {
if (e.target.matches('.btn')) doThing();
});
Mistake 3 — Using innerHTML with user input in delegation templates
// XSS vulnerability — user text goes into innerHTML
function createItem(userText) {
li.innerHTML = `<span>${userText}</span> <button>Delete</button>`;
// If userText contains <script>...</script> — XSS attack!
}
// Fix — set text content separately
function createItem(userText) {
const span = document.createElement('span');
span.textContent = userText; // safe — treats as plain text
const btn = document.createElement('button');
btn.textContent = 'Delete';
btn.dataset.action = 'delete';
li.append(span, btn);
}
Mistake 4 — Relying on delegation when events don't bubble
// Bug — focus does NOT bubble, so this never fires
list.addEventListener('focus', e => {
// this never runs when a child input is focused
});
// Fix — use focusin which bubbles
list.addEventListener('focusin', e => {
const input = e.target.closest('input');
if (input) input.classList.add('active');
});
7. Best Practices
- Attach the listener to the closest stable ancestor — not document or body unless necessary.
- Always guard with closest() or matches() — never assume e.target is the element you want.
- Use data-action attributes to route multiple action types through a single listener.
- Use textContent for user-supplied text in dynamically created elements — never innerHTML.
- Prefer delegation for repeated items — especially any list that grows or shrinks dynamically.
- Use direct listeners for unique elements like a single submit button — delegation adds unnecessary complexity there.
8. Practice Exercise
- Build a simple note list: an "Add Note" button creates new notes. Each note has edit and delete buttons. Use event delegation — one listener for all note actions.
- Build a tab component with three tabs and three content panels. Use delegation to handle tab switching with a single listener on the tab bar.
- Create a table of 50 rows (generated with a loop). Each row has a "Select" button. Use delegation — one listener on the table body. Clicking "Select" highlights the row. Clicking again deselects it.
9. Assignment
Build a full CRUD Todo List using only event delegation.
- A text input and "Add" button to create tasks.
- Each task has: a checkbox to mark done (crosses out text), an "Edit" button (replaces text with an input for inline editing, then saves on blur/Enter), and a "Delete" button.
- Use one click listener on the task list container and one change listener on the same container — no direct listeners on individual tasks.
- Tasks added after page load work automatically.
- All user-supplied text must use
textContent(never innerHTML for user data).
Deliverable: One HTML file.
10. Interview Questions
- What is event delegation?
A pattern where a single event listener is placed on a parent element to handle events from all its children. It works because events bubble up from child to parent. The handler inspects e.target to determine which child was the source. - What are the advantages of event delegation?
Fewer listeners means less memory usage. It works automatically for dynamically added elements — you don't need to re-attach listeners when DOM changes. Code is centralised and easier to manage. - Why do you use closest() in a delegation handler?
Because e.target may be a deeply nested child of the intended element. closest() walks up the DOM from e.target to find the nearest ancestor (or self) matching the selector — giving you the logical target regardless of which internal element was clicked. - What is the difference between e.target.matches() and e.target.closest()?
matches() returns true only if e.target itself matches the selector — it does not look at ancestors. closest() searches e.target AND its ancestors, returning the first match. Use matches() when e.target must be the exact element; use closest() when the click could land on a child inside the intended element. - When should you NOT use event delegation?
When there is only one element (a single submit button), delegation adds unnecessary complexity. Also, delegation cannot be used for events that don't bubble (like focus and blur — use focusin/focusout instead).
11. Additional Resources
- MDN — Element.closest()
- MDN — Element.matches()
- javascript.info — Event delegation — excellent visual walkthrough
- David Walsh Blog — Event Delegation