DocsHub
DOM

Event Delegation

Learn how to handle events efficiently using event delegation in JavaScript.

Event Delegation

In the previous topic we learned that events bubble up through the DOM. Event delegation is a technique that uses bubbling on purpose — instead of adding listeners to individual elements, you add one listener to a parent and let the events bubble up to it.

This sounds simple but it is one of the most powerful and practical patterns in DOM programming.


The Problem It Solves

Imagine you have a list of 100 items and each one needs a click listener.

<ul id="todo-list">
  <li class="item">Variables</li>
  <li class="item">Functions</li>
  <li class="item">Arrays</li>
  <!-- 97 more items... -->
</ul>

The naive approach — add a listener to every item:

const items = document.querySelectorAll(".item");

items.forEach(item => {
  item.addEventListener("click", () => {
    console.log(`Clicked: ${item.textContent}`);
  });
});

This works — but it has two serious problems:

Problem 1 — Performance. 100 event listeners sitting in memory. For large lists this adds up.

Problem 2 — Dynamic elements. If you add a new item to the list after this code runs, the new item has no listener. You would have to manually attach one every time you add an element.

const newItem = document.createElement("li");
newItem.textContent = "Objects";
newItem.classList.add("item");
todoList.appendChild(newItem);
// ❌ This new item has no click listener

Event delegation solves both problems at once.


The Solution — One Listener on the Parent

Instead of listening on each item, listen on the parent ul. When any item is clicked, the event bubbles up to the ul — and you check event.target to see which item was actually clicked.

const list = document.getElementById("todo-list");

list.addEventListener("click", (e) => {
  console.log(`Clicked: ${e.target.textContent}`);
});

One listener. Handles all 100 items. And any new item added later is automatically covered — because it is inside the ul and its events will bubble up.


How It Works

click bubbles up click bubbles up click bubbles up ul#todo-listOne listener here li.item — Variables li.item — Functions li.item — Arrays

The click happens on the li. It bubbles up to the ul. The ul's listener fires — and event.target tells you exactly which li was clicked.


Filtering With event.target

The parent might contain different types of elements. Use event.target to make sure you only react to the right ones.

<ul id="todo-list">
  <li class="item">
    <span class="text">Variables</span>
    <button class="delete-btn">Delete</button>
  </li>
  <li class="item">
    <span class="text">Functions</span>
    <button class="delete-btn">Delete</button>
  </li>
</ul>
const list = document.getElementById("todo-list");

list.addEventListener("click", (e) => {
  // Only react to delete buttons
  if (e.target.classList.contains("delete-btn")) {
    const item = e.target.closest(".item"); // find the parent li
    item.remove();
  }

  // Only react to the text span
  if (e.target.classList.contains("text")) {
    e.target.parentElement.classList.toggle("done");
  }
});

One listener handles two different click targets — delete buttons and text spans — cleanly.


closest() — Finding the Right Parent

closest() walks up the DOM tree from the clicked element and returns the first ancestor that matches a CSS selector. It is the perfect companion to event delegation.

list.addEventListener("click", (e) => {
  // e.target might be a span or button INSIDE the li
  // closest() finds the li no matter what was actually clicked
  const item = e.target.closest(".item");

  if (item) {
    console.log(`Item clicked: ${item.querySelector(".text").textContent}`);
  }
});

Without closest() — if the user clicks the text inside the li rather than the li itself — e.target would be the <span>, not the <li>. closest(".item") handles this by walking up until it finds the .item element.

// closest() returns null if no match found going up the tree
const item = e.target.closest(".item");
if (!item) return; // clicked somewhere outside items — do nothing

Dynamic Elements — The Real Power

This is where event delegation truly shines. New elements added to the DOM after the page loads are automatically covered.

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

// One delegated listener covers ALL items — including future ones
list.addEventListener("click", (e) => {
  if (e.target.classList.contains("delete-btn")) {
    e.target.closest(".item").remove();
  }

  if (e.target.classList.contains("text")) {
    e.target.closest(".item").classList.toggle("done");
  }
});

// Add new items dynamically
addBtn.addEventListener("click", () => {
  const text = input.value.trim();
  if (!text) return;

  const li = document.createElement("li");
  li.className = "item";
  li.innerHTML = `
    <span class="text">${text}</span>
    <button class="delete-btn">Delete</button>
  `;

  list.appendChild(li);
  input.value = "";

  // ✅ No need to add a listener to the new item — delegation handles it
});

Every new item added is immediately interactive — delete and done-toggle both work — without touching the event listener code at all.


When to Use Event Delegation

Not every situation calls for delegation. Here is how to decide:

// Use delegation when:
// 1. Many similar elements need the same behavior
// 2. Elements are added dynamically
// 3. A large list or table needs interaction

// Use direct listeners when:
// 1. Just one or two elements
// 2. Elements are static and never change
// 3. Each element needs completely different behavior
SituationUse
Large list of clickable itemsDelegation
Dynamically added elementsDelegation
One button, one actionDirect listener
Static, non-changing elementsDirect listener
Table rows that can be added/removedDelegation

A Real Example — Data Table With Actions

<table id="users-table">
  <thead>
    <tr>
      <th>Name</th>
      <th>Role</th>
      <th>Actions</th>
    </tr>
  </thead>
  <tbody id="users-body">
    <tr data-id="1">
      <td>Ali Hassan</td>
      <td>Admin</td>
      <td>
        <button class="btn-edit">Edit</button>
        <button class="btn-delete">Delete</button>
      </td>
    </tr>
    <tr data-id="2">
      <td>Sara Khan</td>
      <td>Editor</td>
      <td>
        <button class="btn-edit">Edit</button>
        <button class="btn-delete">Delete</button>
      </td>
    </tr>
  </tbody>
</table>
const tableBody = document.getElementById("users-body");

tableBody.addEventListener("click", (e) => {
  const row = e.target.closest("tr");
  if (!row) return;

  const userId = row.dataset.id;
  const userName = row.cells[0].textContent;

  if (e.target.classList.contains("btn-delete")) {
    if (confirm(`Delete ${userName}?`)) {
      row.remove();
      console.log(`Deleted user ${userId}`);
    }
  }

  if (e.target.classList.contains("btn-edit")) {
    const newName = prompt(`Edit name for user ${userId}:`, userName);
    if (newName) {
      row.cells[0].textContent = newName;
    }
  }
});

// Adding a new user — automatically works with existing listener
function addUser(id, name, role) {
  const row = document.createElement("tr");
  row.dataset.id = id;
  row.innerHTML = `
    <td>${name}</td>
    <td>${role}</td>
    <td>
      <button class="btn-edit">Edit</button>
      <button class="btn-delete">Delete</button>
    </td>
  `;
  tableBody.appendChild(row);
}

addUser(3, "Zara Ahmed", "Viewer");
// New row works with edit and delete immediately — no extra listeners needed

One listener on tbody handles edit and delete for every row — existing and future. closest("tr") finds the right row even if the button's inner span was clicked. dataset.id gives you the data attribute directly.


dataset — Reading Data Attributes

element.dataset gives you easy access to all data-* attributes on an element.

<button data-user-id="42" data-action="delete">Delete</button>
btn.addEventListener("click", (e) => {
  console.log(e.target.dataset.userId);  // "42"
  console.log(e.target.dataset.action);  // "delete"
});

data-user-id becomes dataset.userId — the hyphen is converted to camelCase. This is a clean way to store information in the HTML that JavaScript can read during delegation.


Summary

  • Event delegation uses bubbling to handle many elements with one listener on their parent
  • event.target tells you exactly which element triggered the event
  • Use closest() to find the nearest matching ancestor — handles clicks on nested elements
  • Delegated listeners automatically cover dynamically added elements
  • Use dataset to store and read data attributes from HTML elements
  • Delegation is best for large lists, tables, and any dynamically changing content
  • Direct listeners are fine for single static elements with unique behavior

On this page