How does event delegation work in JS?
The Short Answer
Event delegation is a pattern where you attach a single event listener to a parent element instead of individual listeners on each child. When a child element triggers an event, it bubbles up to the parent, where you inspect event.target to determine which child was clicked. This gives you better performance, simpler code, and automatic handling of dynamically added elements.
How Event Bubbling Makes It Work
Event delegation relies on a fundamental DOM behavior: event bubbling. When you click a button inside a list item inside a <ul>, the click event doesn't just fire on the button — it travels up through every ancestor element until it reaches the document root.
- User clicks a <button> inside a <li>
- Click event fires on the <button> (the target)
- Event bubbles up to the <li>
- Event bubbles up to the <ul>
- Event bubbles up to the <body>, then <html>, then document
- Any listener on any ancestor can catch this event
Because events bubble, you can place a single listener on the <ul> and it will catch clicks on any <li> or any element inside a <li>. The event.target property tells you exactly which element was originally clicked.
Without Delegation vs With Delegation
The naive approach attaches a listener to every single list item. If you have 1000 items, that's 1000 listeners consuming memory. Worse, if you add new items dynamically, they won't have listeners unless you manually attach them.
// ❌ Without delegation — one listener per item
const items = document.querySelectorAll('.todo-item');
items.forEach(item => {
item.addEventListener('click', (event) => {
const target = event.currentTarget as HTMLElement;
target.classList.toggle('completed');
});
});
// Problems:
// 1. 1000 items = 1000 listeners (memory waste)
// 2. New items added later won't have listeners
// 3. Must manually manage attach/detach lifecycle
With delegation, you attach one listener to the parent <ul>. It catches all clicks from any child, present or future. The closest() method finds the nearest ancestor matching a selector — this handles cases where the user clicks a nested element like a <span> inside the <li>.
// ✅ With delegation — one listener on the parent
const list = document.querySelector('.todo-list');
list.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const item = target.closest('.todo-item');
if (!item) return; // Click wasn't on a todo item
item.classList.toggle('completed');
});
// Benefits:
// 1. One listener regardless of how many items exist
// 2. Dynamically added items work automatically
// 3. No cleanup needed when items are removed
The closest() Pattern
A common gotcha with delegation is that event.target might be a nested element — not the element you actually care about. If your list item contains an icon and a text span, clicking the icon gives you the <svg> as the target, not the <li>. The closest() method solves this by walking up the DOM tree to find the nearest ancestor matching your selector.
// HTML structure:
// <ul class="menu">
// <li class="menu-item" data-action="save">
// <svg class="icon">...</svg>
// <span>Save</span>
// </li>
// </ul>
const menu = document.querySelector('.menu');
menu.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
// event.target might be the <svg> or <span>
// closest() finds the <li> we actually want
const menuItem = target.closest('.menu-item');
if (!menuItem) return;
const action = menuItem.dataset.action;
handleAction(action); // "save"
});
Always use closest() instead of checking event.target directly. It handles nested elements gracefully and makes your delegation robust regardless of the internal HTML structure of each item.
Real-World Use Cases
When to use event delegation
- ✅Lists and tables with many rows — one listener handles all row clicks
- ✅Dynamically generated content — new elements work without re-attaching listeners
- ✅Tab bars and navigation menus — one listener on the container handles all tab clicks
- ✅Form with many inputs — one listener on the form handles all change events
- ✅Any UI where elements are frequently added or removed from the DOM
Limitations
When delegation doesn't work well
- ❌Events that don't bubble — `focus`, `blur`, `scroll` (use `focusin`/`focusout` instead for focus)
- ❌When you need `event.stopPropagation()` on child elements — delegation relies on bubbling
- ❌Highly specific per-element behavior where the logic differs completely for each child
React already uses delegation
React attaches a single event listener at the root of your app and uses its synthetic event system to delegate all events. When you write onClick on a component, React isn't adding a listener to that DOM node — it's handling it through delegation at the root. This is why React events behave slightly differently from native DOM events.
Why Interviewers Ask This
Event delegation tests your understanding of DOM event flow (bubbling and capturing), performance awareness (fewer listeners = less memory), and practical problem-solving (handling dynamic content). It's also a gateway to discussing how frameworks like React handle events internally. Interviewers want to see that you understand the underlying browser mechanics, not just framework abstractions.
Quick Revision Cheat Sheet
Core idea: One listener on a parent catches events from all children via bubbling
Key method: `event.target.closest(selector)` to find the relevant child element
Performance: 1 listener vs N listeners — significant for large lists
Dynamic elements: New children automatically handled — no re-attaching needed
Limitation: Only works with events that bubble (not focus, blur, scroll)