Ad – 728Γ—90
🌐 DOM & Browser

JavaScript Event Delegation – One Listener for Many Elements

Event delegation is a pattern where instead of attaching a listener to each child element, you attach one listener to a common ancestor and use event.target to determine which child was interacted with. It relies on event bubbling and dramatically improves performance when working with many or dynamically generated elements.

⏱️ 16 min read 🎯 Intermediate πŸ“… Updated 2026

The Problem Without Delegation

Imagine a list with 1 000 items. Attaching a click listener to each one wastes memory and is fragile β€” dynamically added items get no listener at all:

JavaScript
// ❌ Anti-pattern: listener per child
const items = document.querySelectorAll('.list-item');
items.forEach(item => {
  item.addEventListener('click', (e) => {
    console.log('Clicked:', e.target.textContent);
  });
});

// Problem 1: 1000 listener objects in memory
// Problem 2: items added later get NO listener
const newItem = document.createElement('li');
newItem.className = 'list-item';
newItem.textContent = 'New item';
document.querySelector('ul').appendChild(newItem);
// Clicking newItem β†’ nothing happens!

How Event Delegation Works

Because events bubble up the DOM tree, a click on any <li> also fires on the parent <ul>. We attach one listener to the parent and inspect event.target:

JavaScript
// βœ… Delegation: one listener on parent
const ul = document.querySelector('ul');

ul.addEventListener('click', (e) => {
  // e.target = the element that was actually clicked
  if (e.target.tagName === 'LI') {
    console.log('Clicked item:', e.target.textContent);
  }
});

// Works for dynamically added items too!
const newItem = document.createElement('li');
newItem.textContent = 'Dynamically added';
ul.appendChild(newItem);
// Clicking newItem β†’ "Clicked item: Dynamically added" βœ“
β–Ά Output
Clicked item: Dynamically added

Using .matches() and .closest()

Tag name checks are brittle. Use element.matches(selector) for CSS-selector matching and element.closest(selector) for nested element scenarios:

JavaScript
const list = document.getElementById('todo-list');

list.addEventListener('click', (e) => {
  // matches() – CSS selector check on the exact clicked element
  if (e.target.matches('.delete-btn')) {
    e.target.closest('li').remove();
    return;
  }

  if (e.target.matches('.complete-btn')) {
    e.target.closest('li').classList.toggle('done');
    return;
  }
});

// closest() traverses UP the tree to find the matching ancestor
// Useful when the clicked element is a child of the intended target
// Example: <li><span class="icon">βœ“</span> Task text</li>
list.addEventListener('click', (e) => {
  const li = e.target.closest('li'); // works even if user clicks <span>
  if (li) {
    li.classList.toggle('selected');
  }
});
πŸ’‘
Always use .closest() for nested structures

When a list item contains child elements (icons, badges, buttons), e.target may be the icon, not the <li>. e.target.closest('li') safely walks up the tree to the intended element, regardless of which child was clicked.

Practical Example: Todo List with Delegation

JavaScript
// HTML assumed:
// <input id="todo-input" placeholder="Add task…">
// <button id="add-btn">Add</button>
// <ul id="todo-list"></ul>

const input   = document.getElementById('todo-input');
const addBtn  = document.getElementById('add-btn');
const todoList = document.getElementById('todo-list');

function addItem(text) {
  if (!text.trim()) return;
  const li = document.createElement('li');
  li.innerHTML = `
    <span class="task-text">${text}</span>
    <button class="complete-btn">βœ“</button>
    <button class="delete-btn">βœ•</button>
  `;
  todoList.appendChild(li);
  input.value = '';
}

addBtn.addEventListener('click', () => addItem(input.value));
input.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') addItem(input.value);
});

// ONE delegated listener handles all current and future items
todoList.addEventListener('click', (e) => {
  const li = e.target.closest('li');
  if (!li) return;

  if (e.target.matches('.delete-btn')) {
    li.remove();
  } else if (e.target.matches('.complete-btn')) {
    li.classList.toggle('done');
  }
});

Practical Example: Dynamic Table Actions

JavaScript
const tableBody = document.querySelector('#data-table tbody');

// Delegate on the tbody β€” handles Edit and Delete for any row
tableBody.addEventListener('click', (e) => {
  const btn = e.target.closest('button[data-action]');
  if (!btn) return;

  const row = btn.closest('tr');
  const id  = row.dataset.id;

  switch (btn.dataset.action) {
    case 'edit':
      openEditModal(id);
      break;
    case 'delete':
      if (confirm(`Delete record ${id}?`)) row.remove();
      break;
    case 'view':
      openDetailPanel(id);
      break;
  }
});

// Adding rows dynamically β€” no new listeners needed
function addRow(id, name, email) {
  tableBody.insertAdjacentHTML('beforeend', `
    <tr data-id="${id}">
      <td>${id}</td>
      <td>${name}</td>
      <td>${email}</td>
      <td>
        <button data-action="edit">Edit</button>
        <button data-action="delete">Delete</button>
        <button data-action="view">View</button>
      </td>
    </tr>
  `);
}

Direct vs Delegation – Comparison

AspectDirect ListenersEvent Delegation
MemoryN listeners for N elements1 listener regardless of count
Dynamic elementsMust re-attach after DOM updateWorks automatically
Setup complexitySimple loopNeeds target check
stopPropagation riskNoneChild's stopPropagation breaks delegation
Best forFew, static, complex elementsMany or dynamic child elements

When NOT to Use Delegation

⚠️
Delegation breaks if children call stopPropagation

If a child element's listener calls e.stopPropagation(), the event never reaches your delegate listener on the parent. This is a common source of bugs in third-party widget integration. In those cases, attach a direct listener to the child.

Avoid delegation when: events don't bubble (e.g. focus, blur β€” use focusin/focusout instead); the parent and child are far apart in the tree (performance overhead of bubbling through many nodes); or the child widgets call stopPropagation.

πŸ‹οΈ Practical Exercise

Build an accordion FAQ list using delegation:

  1. Create a <div id="faq"> containing several <div class="faq-item"> elements, each with an <h3 class="question"> and a <p class="answer"> (initially hidden with CSS).
  2. Attach a single click listener to #faq.
  3. When a .question is clicked, toggle a CSS class open on its parent .faq-item to show/hide the answer.
  4. Close all other items when one is opened (accordion behaviour).

πŸ”₯ Challenge Exercise

Build a Kanban-style board with three columns (To Do, In Progress, Done). Use a single delegated listener on the board container. Cards should have "Move Right" and "Move Left" buttons that move them between columns. Cards can be dynamically added via an input in each column. The entire interaction should use fewer than 3 event listeners total.

Summary

πŸ“‹ Summary

  • Delegation attaches one listener to an ancestor instead of many to children.
  • It relies on event bubbling β€” the event from a child travels up to the parent.
  • e.target identifies which child was clicked; use .matches() for CSS-selector checks.
  • e.target.closest(selector) handles clicks on nested child elements inside the target.
  • Delegated listeners automatically handle dynamically added children.
  • Delegation fails if a child calls e.stopPropagation().
  • Use focusin/focusout instead of focus/blur when delegating focus events.

Interview Questions

  • What is event delegation and why is it useful?
  • How does event delegation work with dynamically added elements?
  • What is the difference between e.target and e.currentTarget in a delegated handler?
  • When would you choose a direct listener over delegation?
  • Why might delegation fail on a page that uses third-party widgets?

Frequently Asked Questions

Can I delegate events that don't bubble? +

Not directly. focus and blur do not bubble, so attaching them to a parent won't catch child focus events. Use focusin and focusout instead β€” they are the bubbling equivalents. Alternatively, you can use the capture phase (addEventListener('focus', handler, true)) which runs top-down before bubbling.

Is event delegation always faster than direct listeners? +

For large or dynamic lists, yes β€” delegation uses far less memory and avoids the overhead of attaching/removing listeners. For a small fixed set of elements (say, 3 buttons), direct listeners are simpler and the performance difference is negligible. Choose based on readability and the dynamic nature of your elements.

How do I handle multiple action types in one delegated listener? +

Use data-action attributes on the child elements and check e.target.dataset.action (or e.target.closest('[data-action]').dataset.action for nested elements) inside a switch or if/else chain. This cleanly separates logic per action without cluttering the selector checks.

Ad – 336Γ—280