Event Delegation & Bubbling

Difficulty: Intermediate

Question

Explain event bubbling, capturing, and delegation. Why is event delegation important?

Answer

When an event fires on a DOM element, it goes through three phases: 1. Capturing phase: The event travels from the window down to the target element. 2. Target phase: The event reaches the target element. 3. Bubbling phase: The event bubbles back up from the target to the window.

By default, event listeners fire during the bubbling phase. You can listen during the capture phase by passing { capture: true } to addEventListener.

Event delegation is a pattern where you attach a single event listener to a parent element instead of individual listeners to each child. When a child fires an event, it bubbles up to the parent where you check event.target to determine which child was clicked.

Benefits: Better memory usage (fewer listeners), automatically handles dynamically added elements, and simpler code. This is the same principle React uses under the hood with its synthetic event system.

Code examples

Event Bubbling and Capturing

// <div id="outer">
//   <div id="inner">
//     <button id="btn">Click</button>
//   </div>
// </div>

const outer = document.getElementById('outer');
const inner = document.getElementById('inner');
const btn = document.getElementById('btn');

// Bubbling (default) - fires bottom-up
outer.addEventListener('click', () => console.log('Outer (bubble)'));
inner.addEventListener('click', () => console.log('Inner (bubble)'));
btn.addEventListener('click', () => console.log('Button (bubble)'));

// Capturing - fires top-down
outer.addEventListener('click', () => console.log('Outer (capture)'), true);
inner.addEventListener('click', () => console.log('Inner (capture)'), true);

// When button is clicked, the order is:
// Capture phase (top-down): Outer capture -> Inner capture
// Target phase: Button bubble
// Bubble phase (bottom-up): Inner bubble -> Outer bubble
console.log('Click order: Outer(cap) -> Inner(cap) -> Button -> Inner(bub) -> Outer(bub)');

Events travel down (capture) then back up (bubble). Most code uses bubbling. Capture is rare but useful for intercepting events before they reach targets.

Event Delegation Pattern

// Instead of adding a listener to EACH button:
// BAD - O(n) listeners
// document.querySelectorAll('.item-btn').forEach(btn => {
//   btn.addEventListener('click', handleClick);
// });

// GOOD - Event delegation: ONE listener on parent
const todoList = document.getElementById('todo-list');

todoList.addEventListener('click', (event) => {
  const target = event.target;

  // Check which element was clicked
  if (target.matches('.delete-btn')) {
    const item = target.closest('.todo-item');
    console.log(`Delete: ${item.dataset.id}`);
    item.remove();
  }

  if (target.matches('.toggle-btn')) {
    const item = target.closest('.todo-item');
    item.classList.toggle('completed');
    console.log(`Toggle: ${item.dataset.id}`);
  }

  if (target.matches('.edit-btn')) {
    const item = target.closest('.todo-item');
    console.log(`Edit: ${item.dataset.id}`);
  }
});

// Dynamically added items work automatically!
const newItem = document.createElement('li');
newItem.className = 'todo-item';
newItem.dataset.id = '4';
newItem.innerHTML = '<span>New Task</span> <button class="delete-btn">X</button>';
todoList.appendChild(newItem);
console.log('New item added - click handler works without new listener!');

One listener on the parent handles all children. event.target identifies the clicked element. target.closest() finds the ancestor. Dynamically added elements are handled automatically.

stopPropagation and preventDefault

// stopPropagation - stops bubbling/capturing
const parent = document.getElementById('parent');
const child = document.getElementById('child');

parent.addEventListener('click', () => {
  console.log('Parent clicked');
});

child.addEventListener('click', (e) => {
  e.stopPropagation(); // parent handler will NOT fire
  console.log('Child clicked (stopped propagation)');
});

// preventDefault - cancels default browser behavior
const form = document.getElementById('myForm');
form.addEventListener('submit', (e) => {
  e.preventDefault(); // prevent page refresh
  console.log('Form submitted via JS');
  const data = new FormData(form);
  // handle form data...
});

// stopImmediatePropagation - stops other handlers on SAME element
const btn2 = document.getElementById('btn');
btn2.addEventListener('click', (e) => {
  console.log('Handler 1');
  e.stopImmediatePropagation();
});
btn2.addEventListener('click', () => {
  console.log('Handler 2'); // NEVER fires
});

console.log('stopPropagation: prevents bubbling');
console.log('preventDefault: prevents default browser action');
console.log('stopImmediatePropagation: prevents same-element handlers');

stopPropagation stops the event from reaching other elements. preventDefault stops default behavior (form submit, link navigation). stopImmediatePropagation stops other handlers on the same element.

Key points

Concepts covered

Event Delegation, Event Bubbling, Event Capturing, stopPropagation, Event Target