Home Module 11 Event Delegation

1. Introduction

Event delegation is a pattern that uses bubbling deliberately. Instead of attaching a listener to every child element, you attach one listener to their common parent. When a child is clicked, the event bubbles up and the parent's listener handles it — inspecting e.target to decide what to do.

This pattern is essential for two scenarios: lists with many items (efficiency) and lists where items are added dynamically (correctness — you cannot attach listeners to elements that don't exist yet).

2. Theory

2.1 The problem without delegation

// Without delegation — attach to every item
const items = document.querySelectorAll('.todo-item');

items.forEach(item => {
  item.addEventListener('click', () => {
    item.classList.toggle('done');
  });
});

// Problem 1: 1000 items = 1000 listeners (memory intensive)
// Problem 2: items added AFTER this code runs have NO listener!

2.2 Event delegation — the solution

// With delegation — ONE listener on the parent
const list = document.querySelector('.todo-list');

list.addEventListener('click', e => {
  // e.target is the element that was actually clicked
  const item = e.target.closest('.todo-item');
  if (!item) return; // clicked something that isn't a todo item

  item.classList.toggle('done');
});

// Works for all items — including ones added later!

2.3 Why closest() is the key

Each todo item might contain nested elements (a checkbox, a span, a delete button). When the user clicks the span inside the item, e.target is the span — not the item. closest() walks up the DOM from e.target until it finds a matching ancestor.

// HTML structure:
// <ul class="todo-list">          <-- listener is here
//   <li class="todo-item">
//     <input type="checkbox">
//     <span>Buy groceries</span>  <-- user clicks this span
//     <button class="delete">X</button>
//   </li>
// </ul>

list.addEventListener('click', e => {
  // e.target might be: input, span, button, or li
  const item   = e.target.closest('.todo-item');  // always finds the li
  const del    = e.target.closest('.delete');      // only truthy for delete btn
  if (!item) return; // click outside any item

  if (del) {
    item.remove(); // delete this item
  } else {
    item.classList.toggle('done'); // toggle completion
  }
});

2.4 Handling multiple actions with data attributes

// Give each button a data-action attribute
// <button data-action="edit">Edit</button>
// <button data-action="delete">Delete</button>
// <button data-action="share">Share</button>

container.addEventListener('click', e => {
  const btn    = e.target.closest('[data-action]');
  if (!btn) return;

  const action = btn.dataset.action;
  const card   = btn.closest('.card');

  if (action === 'edit')   openEditor(card);
  if (action === 'delete') card.remove();
  if (action === 'share')  shareCard(card);
});

2.5 Handling dynamically added elements

// Delegation works automatically for elements added later
const list = document.querySelector('#task-list');

// Listener added ONCE at start
list.addEventListener('click', e => {
  const item = e.target.closest('.task');
  if (item) item.classList.toggle('done');
});

// Add tasks at any time — they're automatically handled
function addTask(text) {
  const li = document.createElement('li');
  li.className = 'task';
  li.textContent = text;
  list.appendChild(li); // instant — no need to re-attach listeners
}

addTask('Learn JavaScript');
addTask('Build a project');
setTimeout(() => addTask('Deploy to GitHub'), 2000); // works too

2.6 The guard pattern — checking containment

// Always guard against clicks outside intended targets
parent.addEventListener('click', e => {
  // Method 1: closest() returns null if not found
  const target = e.target.closest('.item');
  if (!target) return; // early return is cleaner than wrapping in if

  // Method 2: matches() — check if e.target itself matches
  if (!e.target.matches('.btn')) return;

  // Method 3: contains() — check if parent contains the target
  if (!list.contains(e.target)) return;

  // Proceed safely
  handleItem(target);
});

2.7 When NOT to use delegation

// Delegation is overkill for static, one-off elements
// Just use direct listeners here:
document.querySelector('#submit-btn').addEventListener('click', handleSubmit);
document.querySelector('#cancel-btn').addEventListener('click', handleCancel);

// Delegation shines for:
// - Lists with many items (10+)
// - Lists where items are added/removed dynamically
// - Multiple action types within repeated card/item components

2.8 Tabbed interface with delegation

const tabs    = document.querySelector('.tab-bar');
const panels  = document.querySelectorAll('.tab-panel');

tabs.addEventListener('click', e => {
  const tab = e.target.closest('[data-tab]');
  if (!tab) return;

  const target = tab.dataset.tab; // e.g. "about"

  // Deactivate all
  tabs.querySelectorAll('[data-tab]').forEach(t => t.classList.remove('active'));
  panels.forEach(p => p.classList.remove('active'));

  // Activate selected
  tab.classList.add('active');
  document.querySelector(`#panel-${target}`).classList.add('active');
});

3. Real World Example

// Shopping cart — delegation handles edit, delete, quantity change
const cart = document.querySelector('#cart-items');

cart.addEventListener('click', e => {
  const btn  = e.target.closest('button[data-action]');
  if (!btn) return;

  const row    = btn.closest('.cart-row');
  const itemId = row.dataset.id;
  const action = btn.dataset.action;

  if (action === 'increase') {
    updateQuantity(itemId, +1);
  } else if (action === 'decrease') {
    updateQuantity(itemId, -1);
  } else if (action === 'remove') {
    removeItem(itemId);
    row.remove();
  }
});

cart.addEventListener('change', e => {
  const input = e.target.closest('input[data-field="qty"]');
  if (!input) return;

  const row    = input.closest('.cart-row');
  const itemId = row.dataset.id;
  const qty    = parseInt(input.value, 10);

  if (qty < 1) { input.value = 1; return; }
  setQuantity(itemId, qty);
});

// Adding a new item to cart later — no extra listener setup needed
function addToCart(product) {
  const row = createCartRow(product); // builds the .cart-row element
  cart.appendChild(row);              // parent listener handles it automatically
}

4. Code Example

<div>
  <button id="add-task">Add Task</button>
  <ul id="task-list"></ul>
</div>

<script>
  const list    = document.querySelector('#task-list');
  const addBtn  = document.querySelector('#add-task');
  let taskCount = 0;

  // ONE listener handles ALL tasks — including future ones
  list.addEventListener('click', e => {
    const toggleBtn = e.target.closest('[data-action="toggle"]');
    const deleteBtn = e.target.closest('[data-action="delete"]');

    if (toggleBtn) {
      const item = toggleBtn.closest('.task-item');
      item.classList.toggle('done');
      toggleBtn.textContent = item.classList.contains('done') ? 'Undo' : 'Done';
    }

    if (deleteBtn) {
      deleteBtn.closest('.task-item').remove();
    }
  });

  function createTask(text) {
    const li = document.createElement('li');
    li.className = 'task-item';
    li.innerHTML = `
      <span>${text}</span>
      <button data-action="toggle">Done</button>
      <button data-action="delete">Delete</button>
    `;
    return li;
  }

  addBtn.addEventListener('click', () => {
    taskCount++;
    list.appendChild(createTask(`Task ${taskCount}`));
  });
</script>

5. Code Breakdown

Single listener on the list

The listener is attached once to #task-list at page load. Every task added later — whether immediately or after a timeout — is automatically covered because the click bubbles up to the list element.

data-action routing

Each button gets a data-action attribute. The delegation handler reads this attribute to decide what to do, keeping all task logic in one place rather than split across many closures.

closest() for reliable targeting

The "Done" button contains only text, so e.target will be the button itself. But closest('.task-item') reliably walks up to the containing list item regardless of which child element was clicked.

innerHTML with static template strings

The task text is inserted via innerHTML here for brevity. In real apps, use textContent for user-supplied text to prevent XSS — replace <span>${text}</span> with a createElement and textContent assignment.

6. Common Mistakes

Mistake 1 — Forgetting the guard check

// Bug — e.target may be the container itself or any descendant
list.addEventListener('click', e => {
  e.target.classList.toggle('done'); // crashes if e.target is the list ul itself
});

// Fix — guard with closest
list.addEventListener('click', e => {
  const item = e.target.closest('.task-item');
  if (!item) return;
  item.classList.toggle('done');
});

Mistake 2 — Attaching delegation too high

// Avoid — attaching to document for everything
document.addEventListener('click', e => {
  if (e.target.matches('.btn')) doThing();
});
// This fires for EVERY click on the page. Use the closest parent instead.

// Better
document.querySelector('.toolbar').addEventListener('click', e => {
  if (e.target.matches('.btn')) doThing();
});

Mistake 3 — Using innerHTML with user input in delegation templates

// XSS vulnerability — user text goes into innerHTML
function createItem(userText) {
  li.innerHTML = `<span>${userText}</span> <button>Delete</button>`;
  // If userText contains <script>...</script> — XSS attack!
}

// Fix — set text content separately
function createItem(userText) {
  const span = document.createElement('span');
  span.textContent = userText; // safe — treats as plain text
  const btn = document.createElement('button');
  btn.textContent = 'Delete';
  btn.dataset.action = 'delete';
  li.append(span, btn);
}

Mistake 4 — Relying on delegation when events don't bubble

// Bug — focus does NOT bubble, so this never fires
list.addEventListener('focus', e => {
  // this never runs when a child input is focused
});

// Fix — use focusin which bubbles
list.addEventListener('focusin', e => {
  const input = e.target.closest('input');
  if (input) input.classList.add('active');
});

7. Best Practices

  1. Attach the listener to the closest stable ancestor — not document or body unless necessary.
  2. Always guard with closest() or matches() — never assume e.target is the element you want.
  3. Use data-action attributes to route multiple action types through a single listener.
  4. Use textContent for user-supplied text in dynamically created elements — never innerHTML.
  5. Prefer delegation for repeated items — especially any list that grows or shrinks dynamically.
  6. Use direct listeners for unique elements like a single submit button — delegation adds unnecessary complexity there.

8. Practice Exercise

  1. Build a simple note list: an "Add Note" button creates new notes. Each note has edit and delete buttons. Use event delegation — one listener for all note actions.
  2. Build a tab component with three tabs and three content panels. Use delegation to handle tab switching with a single listener on the tab bar.
  3. Create a table of 50 rows (generated with a loop). Each row has a "Select" button. Use delegation — one listener on the table body. Clicking "Select" highlights the row. Clicking again deselects it.

9. Assignment

Build a full CRUD Todo List using only event delegation.

  1. A text input and "Add" button to create tasks.
  2. Each task has: a checkbox to mark done (crosses out text), an "Edit" button (replaces text with an input for inline editing, then saves on blur/Enter), and a "Delete" button.
  3. Use one click listener on the task list container and one change listener on the same container — no direct listeners on individual tasks.
  4. Tasks added after page load work automatically.
  5. All user-supplied text must use textContent (never innerHTML for user data).

Deliverable: One HTML file.

10. Interview Questions

  1. What is event delegation?
    A pattern where a single event listener is placed on a parent element to handle events from all its children. It works because events bubble up from child to parent. The handler inspects e.target to determine which child was the source.
  2. What are the advantages of event delegation?
    Fewer listeners means less memory usage. It works automatically for dynamically added elements — you don't need to re-attach listeners when DOM changes. Code is centralised and easier to manage.
  3. Why do you use closest() in a delegation handler?
    Because e.target may be a deeply nested child of the intended element. closest() walks up the DOM from e.target to find the nearest ancestor (or self) matching the selector — giving you the logical target regardless of which internal element was clicked.
  4. What is the difference between e.target.matches() and e.target.closest()?
    matches() returns true only if e.target itself matches the selector — it does not look at ancestors. closest() searches e.target AND its ancestors, returning the first match. Use matches() when e.target must be the exact element; use closest() when the click could land on a child inside the intended element.
  5. When should you NOT use event delegation?
    When there is only one element (a single submit button), delegation adds unnecessary complexity. Also, delegation cannot be used for events that don't bubble (like focus and blur — use focusin/focusout instead).

11. Additional Resources

  • MDN — Element.closest()
  • MDN — Element.matches()
  • javascript.info — Event delegation — excellent visual walkthrough
  • David Walsh Blog — Event Delegation