Skip to main content

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.target is the clicked element; event.currentTarget is 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

html
<ul id="menu"> <li data-action="home">Home</li> <li data-action="about">About</li> </ul>
javascript
// 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:

javascript
// 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:

javascript
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():

javascript
// Somewhere in your code: child.addEventListener('click', (e) => e.stopPropagation()); // Kills bubbling // Your parent listener never fires now parent.addEventListener('click', handler); // Nothing happens

If you control both listeners, avoid stopPropagation(). If you don't, check e.defaultPrevented as a signal.

Delegating on document without scoping:

javascript
// 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-on on a container with event.target.closest() for dynamic v-for lists.
  • 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

html
<ul id="nav"> <li data-page="home">Home</li> <li data-page="about">About</li> <li data-page="contact">Contact</li> </ul>
javascript
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

javascript
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 needed

The addTodo function keeps adding items. The listener never changes. That's the whole point.

Advanced: multiple actions from one container

javascript
// 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?