Event Delegation: The Most Critical Pattern
100 buttons hain, 100 event listeners lagaoge? MEMORY KHARAB! Ek listener lagao parent pe, target se pata karo. Yeh EVENT DELEGATION hai.
Event delegation means adding ONE event listener to a parent element instead of N listeners to each child. When a child is clicked, the event bubbles up to the parent, which handles it by checking e.target.
Benefit 1 — Memory: 1 listener instead of N. 100 buttons = 100 listeners vs 1 listener. Each addEventListener creates a new function object — 100 functions eat memory for no reason.
Benefit 2 — Dynamic elements: New children automatically work. No need to attach listeners to elements that don't exist yet.
Benefit 3 — Setup simplicity: No re-attaching listeners when the DOM changes. Add or remove children freely.
Benefit 4 — Performance: Fewer listeners = faster event dispatch and less memory pressure on V8.
The key insight: Events bubble. A click on a child reaches the parent. The parent can handle it.
// ❌ WITHOUT delegation: N listeners (bad for memory)
document.querySelectorAll(".item").forEach(item => {
item.addEventListener("click", () => {
console.log("Clicked:", item.textContent);
});
});
// 100 items = 100 function objects in memory = 100 listener registrations
// ✅ WITH delegation: 1 listener (memory efficient!)
document.getElementById("list").addEventListener("click", (e) => {
if (e.target.matches(".item")) {
console.log("Clicked:", e.target.textContent);
}
});
// 1 function object in memory. Works for 1 item or 10,000 items.
// Dynamic elements work automatically!
const newItem = document.createElement("li");
newItem.className = "item";
newItem.textContent = "New Dynamic Item";
list.appendChild(newItem);
// Click works! No new listener needed! Delegation handles it.The problem: <button class="item"><span>Text</span></button> — click on the span, e.target = span, NOT the button!
e.target.matches(".item") returns false because the span doesn't have class "item". The click is silently ignored. This is the nested element problem.
Fix: e.target.closest(".item") — walks UP from the clicked element to find the nearest ancestor (or self) with class "item".
Safety check: parent.contains(matched) — ensures the matched element is INSIDE the parent, not somewhere outside on the page.
// The nested element problem
// HTML: <li class="item"><span class="name">Sai</span><button class="delete">X</button></li>
list.addEventListener("click", (e) => {
// ❌ e.target might be the span or button, not the .item
// if (e.target.matches(".item")) // FAILS if span was clicked!
// ✅ closest() walks UP to find .item
const item = e.target.closest(".item");
if (item && list.contains(item)) {
console.log("Item clicked:", item.dataset.id);
// contains() ensures the matched element is INSIDE our list
// Prevents false matches from elements outside the list
}
});
// Multiple actions with closest
list.addEventListener("click", (e) => {
const deleteBtn = e.target.closest(".delete");
if (deleteBtn && list.contains(deleteBtn)) {
const item = deleteBtn.closest(".item");
item.remove(); // Delete the item
return;
}
const editBtn = e.target.closest(".edit");
if (editBtn && list.contains(editBtn)) {
const item = editBtn.closest(".item");
editItem(item.dataset.id); // Edit the item
return;
}
});
Rule: ONLY bubbling events can be delegated. Non-bubbling events never reach the parent listener.
focus/blur do NOT bubble → use focusin/focusout (they DO bubble).
mouseenter/mouseleave do NOT bubble → use mouseover/mouseout (they DO bubble).
scroll does NOT bubble → must attach to the scrolling element directly.
load does NOT bubble (but it does on window) → can delegate window load.
Most common events (click, input, change, keydown, keyup, submit) all bubble → delegation works perfectly! ✅
// ❌ Can't delegate: focus/blur (don't bubble)
form.addEventListener("focus", (e) => {
// This handler NEVER fires for child inputs!
// focus doesn't bubble — it only fires on the element itself.
});
// ✅ Use focusin/focusout (they DO bubble)
form.addEventListener("focusin", (e) => {
const field = e.target;
if (field.matches("input, textarea, select")) {
field.classList.add("focused");
console.log("Focused:", field.name);
}
});
form.addEventListener("focusout", (e) => {
const field = e.target;
field.classList.remove("focused");
// Validate on blur
if (!field.checkValidity()) {
showFieldError(field, field.validationMessage);
}
});
// ✅ input event bubbles — great for form validation
form.addEventListener("input", (e) => {
const field = e.target;
if (field.matches("input, textarea")) {
validateField(field); // Real-time validation via delegation!
}
});data-action pattern: <button data-action="delete" data-id="123">Delete</button>
Handler reads action: const action = e.target.closest("[data-action]")?.dataset.action
Switch on action: dispatch to different handler functions based on the action value.
data-id, data-params, data-url: Pass any metadata through HTML attributes. The button declares what it does, and the handler dispatches accordingly.
This is how frameworks like Stimulus.js and Alpine.js work internally.
// Data-attribute driven delegation
const app = document.getElementById("app");
app.addEventListener("click", (e) => {
const actionEl = e.target.closest("[data-action]");
if (!actionEl || !app.contains(actionEl)) return;
const action = actionEl.dataset.action;
const id = actionEl.dataset.id;
switch (action) {
case "delete":
deleteItem(id);
break;
case "edit":
editItem(id);
break;
case "toggle":
toggleItem(id);
break;
case "navigate":
navigateTo(actionEl.dataset.url);
break;
default:
console.warn("Unknown action:", action);
}
});
// HTML: Just add data attributes — no JS needed per element!
// <button data-action="delete" data-id="1">Delete</button>
// <button data-action="edit" data-id="1">Edit</button>
// <a data-action="navigate" data-url="/home">Home</a>
// New dynamic elements work immediately!
const newBtn = document.createElement("button");
newBtn.dataset.action = "delete";
newBtn.dataset.id = "999";
newBtn.textContent = "Delete New";
document.getElementById("list").appendChild(newBtn);
// Click works! No listener attachment needed.
Not every situation needs delegation. Here's when to avoid it:
Don't delegate when:
1. Non-bubbling events — focus/blur, mouseenter/mouseleave, scroll — delegation simply won't work.
2. Very different handlers per child — if each button does something completely unrelated, delegation adds complexity for no benefit.
3. Need stopPropagation per-child — if you need to stop bubbling at specific children, delegation works against you.
4. Parent is document/body on a huge page — your handler runs on every click on the entire page.
Don't delegate too high: document.addEventListener("click", ...) catches ALL clicks — navigation, modals, unrelated UI. Wasteful and potentially buggy.
Don't delegate too low: Immediate parent with 1 child — no benefit, just add the listener on the child directly.
Sweet spot: The closest common ancestor of all elements that need the same handler.
// ❌ Don't delegate on document — too broad
document.addEventListener("click", (e) => {
// This runs on EVERY click on the ENTIRE page
// Navigation clicks, modal clicks, unrelated UI clicks
// Even clicks on elements that don't need handling
// Wasteful and potentially buggy
});
// ✅ Delegate on the specific container
document.getElementById("todo-list").addEventListener("click", (e) => {
const item = e.target.closest(".todo-item");
if (item && list.contains(item)) {
// Only fires for clicks INSIDE the todo list
handleTodoClick(item);
}
});
// ✅ Form delegation — the right container
const form = document.getElementById("signup-form");
form.addEventListener("click", (e) => {
const submitBtn = e.target.closest("button[type=submit]");
if (submitBtn && form.contains(submitBtn)) {
e.preventDefault();
validateAndSubmit(form);
}
});
// ✅ Keyboard delegation
const searchBox = document.getElementById("search-container");
searchBox.addEventListener("keydown", (e) => {
const input = e.target.closest("input[type=search]");
if (input && e.key === "Enter") {
performSearch(input.value);
}
});Lo kar liya — Key Points:
- ✅ Event delegation uses 1 parent listener instead of N child listeners — saves memory and handles dynamic elements automatically
- ✅ Use e.target.closest(".selector") instead of e.target.matches() to handle nested elements inside the target
- ✅ Always check parent.contains(matched) to ensure the matched element is inside the delegation container
- ✅ Only BUBBLING events can be delegated — use focusin/focusout instead of focus/blur, mouseover/mouseout instead of mouseenter/mouseleave
- ✅ data-action + data-id attributes let HTML declare actions and JS dispatch to handlers — clean and scalable
- ✅ Don't delegate on document/body — use the closest common ancestor as the delegation container
- ✅ The contains() check prevents false matches from elements outside the delegation container
Want to track your progress?
Log in to save your place and pick up where you left off.
Progress track karna chahte ho?
Login karo apni progress save karne ke liye aur jahan chhoda tha wahan se shuru karo.
Login