Event delegation
Event delegation is a pattern where you attach one event listener to a parent element and let event bubbling carry clicks from any child up to that single handler.
Theory
TL;DR
- Think of it like a hotel front desk: guests from any room walk up to one spot instead of each room having its own staff.
- Main difference: one parent listener vs. one listener per child. The parent version survives DOM changes without rebinding.
event.targetis the clicked element;event.currentTargetis always the parent with the listener.- Use when children are dynamic (API-loaded lists, search results) or numerous (50+).
- For 3-5 static items that never change, direct listeners are simpler.
Quick example
<ul id="menu">
<li data-action="home">Home</li>
<li data-action="about">About</li>
</ul>// Without delegation - breaks when list changes
// document.querySelectorAll('li').forEach(li => li.addEventListener('click', handler));
// With delegation - works for any li, present or future
document.getElementById('menu').addEventListener('click', (e) => {
if (e.target.matches('li')) {
console.log(e.target.dataset.action); // "home" or "about"
}
});Add a new <li data-action="contact">Contact</li> at runtime and the handler still fires. No rebinding.
Key difference
Direct listeners attach to each child at the moment you call addEventListener. Add a new child later and it has no listener. Delegation flips this: the parent is already listening, and new children bubble events up to it automatically. The cost is one check per click via matches() or closest(), but that is never the bottleneck.
When to use
- Dynamic lists (search results, infinite scroll, todo items from an API) → delegate to the container.
- More than 50 similar children → one listener beats dozens.
- Mobile or performance-sensitive UI → fewer listeners means less memory pressure.
- 3-5 static items that never change → direct listeners are cleaner.
- Shadow DOM or custom elements → check first, events don't cross shadow boundaries by default.
How it works internally
Browsers dispatch events in three phases: capture (root to target), target, then bubbling (target back to root). Delegation runs in the bubbling phase, which is the default. When a user clicks a <li>, the event hits that element, then travels up through <ul>, <body>, <html>, document. Your parent listener intercepts it during that trip.
The browser gives you event.target (the original clicked element) and event.currentTarget (the element where your listener lives). That distinction is what makes the whole pattern work.
I've seen this fix a real memory leak in a chat app that was re-attaching listeners to message rows on every WebSocket update. One delegated listener on the list container was the entire fix.
Common mistakes
Forgetting to filter by element type:
// Wrong - fires on the ul itself or any nested span/img inside li
parent.addEventListener('click', (e) => console.log(e.target.textContent));
// Right - check for the exact element
parent.addEventListener('click', (e) => {
const li = e.target.closest('li');
if (li) console.log(li.textContent);
});closest() is safer than matches() alone when your <li> contains icons or <span> tags.
Confusing target with currentTarget:
parent.addEventListener('click', (e) => {
console.log(e.currentTarget); // Always the parent - not what you want
console.log(e.target); // The actual clicked element - this is it
});A child calling stopPropagation():
// Somewhere in your code:
child.addEventListener('click', (e) => e.stopPropagation()); // Kills bubbling
// Your parent listener never fires now
parent.addEventListener('click', handler); // Nothing happensIf you control both listeners, avoid stopPropagation(). If you don't, check e.defaultPrevented as a signal.
Delegating on document without scoping:
// Catches every click on the page - fragile
document.addEventListener('click', (e) => {
if (e.target.tagName === 'LI') doSomething();
});
// Better - scope to a specific container
document.getElementById('menu').addEventListener('click', (e) => {
if (e.target.matches('li')) doSomething();
});Using delegation with non-bubbling events:
focus, blur, and scroll don't bubble. Use focusin/focusout instead (they do bubble), or pass {capture: true} to the listener.
Real-world usage
- React: attaches one synthetic event listener to the root container, not to each component separately.
- jQuery:
$(parent).on('click', '.child', handler)has built-in delegation since v1.7. - Vue 3:
v-onon a container withevent.target.closest()for dynamicv-forlists. - Alpine.js and other lightweight frameworks use this as a core pattern to stay lean.
Follow-up questions
Q: What is the difference between event.target and event.currentTarget?
A: target is the element the user actually clicked. currentTarget is the element where the listener is attached. In delegation, currentTarget is always the parent; target is the child you care about.
Q: How does delegation handle dynamically added elements?
A: Automatically. The parent listener was attached once and stays. New children bubble events up to it without any rebinding.
Q: Which events don't bubble and can't use standard delegation?
A: focus, blur, scroll, mouseenter, mouseleave. Use focusin/focusout as bubbling alternatives, or {capture: true} for the others.
Q: What happens if a child calls stopPropagation()?
A: Bubbling stops at that child and the parent listener never fires. This is the most common reason delegation quietly breaks in large codebases.
Q: How does delegation work in Shadow DOM? (senior-level)
A: Events don't cross shadow boundaries by default. Use event.composedPath() to inspect the full path, or dispatch custom events with {bubbles: true, composed: true} to pierce them.
Examples
Basic: menu with data attributes
<ul id="nav">
<li data-page="home">Home</li>
<li data-page="about">About</li>
<li data-page="contact">Contact</li>
</ul>document.getElementById('nav').addEventListener('click', (e) => {
const item = e.target.closest('li');
if (!item) return;
console.log('Navigate to:', item.dataset.page);
// Output: "Navigate to: home" when Home is clicked
});closest() walks up from the clicked element until it finds a <li>. This handles clicks on a nested icon or <span> inside the list item without any extra logic.
Intermediate: dynamic todo list
function addTodo(text) {
const li = document.createElement('li');
li.textContent = text;
li.dataset.done = 'false';
document.getElementById('todos').appendChild(li);
}
// One listener handles all todos, including ones added after page load
document.getElementById('todos').addEventListener('click', (e) => {
const todo = e.target.closest('li');
if (!todo) return;
const isDone = todo.dataset.done === 'true';
todo.dataset.done = String(!isDone);
todo.style.textDecoration = isDone ? 'none' : 'line-through';
console.log('Toggled:', todo.textContent);
});
addTodo('Buy groceries');
addTodo('Write tests');
// Click either item - both toggle correctly, no re-registration neededThe addTodo function keeps adding items. The listener never changes. That's the whole point.
Advanced: multiple actions from one container
// Each button declares its action via data-action
const actions = {
delete: (el) => el.closest('li').remove(),
edit: (el) => {
const li = el.closest('li');
const text = prompt('Edit:', li.dataset.text);
if (text) li.dataset.text = text;
},
complete: (el) => el.closest('li').classList.toggle('done'),
};
document.getElementById('list').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (actions[action]) actions[action](btn);
// One listener routes to the right handler based on data attribute
});Adding a new action means adding one entry to the actions object. No new listeners, no extra bindings. This pattern scales cleanly across large lists.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.