Event Propagation

When an event happens on an element, it does not only trigger handlers on that element. The event travels through the DOM tree, so handlers on the elements above it can run too.

The Three Phases

Event propagation has 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
Window
  └── Document
        └── <html>
              └── <body>
                    └── <div id="parent">
                          └── <button id="child">  ← Click here

When you click the button:

  1. Capturing: window → document → html → body → div → button
  2. Target: button
  3. Bubbling: button → div → body → html → document → window

Bubbling (Default Behavior)

By default, event listeners run during the bubbling phase:

const parent = document.querySelector("#parent");
const child = document.querySelector("#child");

parent.addEventListener("click", () => console.log("Parent clicked"));
child.addEventListener("click", () => console.log("Child clicked"));

// Clicking the button logs:
// "Child clicked"
// "Parent clicked"

The event “bubbles up” from child to parent.

Capturing Phase

To listen during the capturing phase, pass true or { capture: true }:

parent.addEventListener("click", () => console.log("Parent (capture)"), true);
parent.addEventListener("click", () => console.log("Parent (bubble)"));
child.addEventListener("click", () => console.log("Child"));

// Clicking the button logs:
// "Parent (capture)"  - Capturing phase
// "Child"             - Target phase
// "Parent (bubble)"   - Bubbling phase

Stopping Propagation with stopPropagation()

Use stopPropagation() to prevent an event from continuing to propagate:

child.addEventListener("click", (event) => {
  event.stopPropagation();
  console.log("Child clicked - propagation stopped");
});

parent.addEventListener("click", () => {
  console.log("Parent clicked"); // Never runs when child is clicked
});

Use stopImmediatePropagation() to also prevent other listeners on the same element:

child.addEventListener("click", (event) => {
  event.stopImmediatePropagation();
  console.log("First handler");
});

child.addEventListener("click", () => {
  console.log("Second handler"); // Never runs
});

Event Delegation

Event delegation is a pattern where you attach a single listener to a parent element instead of multiple listeners to child elements. The event bubbles up to the parent, where you check which child triggered it.

Without delegation (inefficient for many elements):

// Adding listeners to every button
document.querySelectorAll(".delete-btn").forEach((btn) => {
  btn.addEventListener("click", handleDelete);
});

With delegation (one listener handles all):

// One listener on the container
document
  .querySelector("#button-container")
  .addEventListener("click", (event) => {
    if (event.target.matches(".delete-btn")) {
      handleDelete(event);
    }
  });

Benefits of Event Delegation

  1. Fewer event listeners: Better memory usage and performance
  2. Works with dynamic elements: New elements automatically handled
  3. Simpler cleanup: Only one listener to remove

Handling Dynamic Content

Delegation is especially useful when elements are added after page load:

const list = document.querySelector("#todo-list");

// This works for items added later
list.addEventListener("click", (event) => {
  if (event.target.matches(".delete")) {
    event.target.closest("li").remove();
  }
  if (event.target.matches(".toggle")) {
    event.target.closest("li").classList.toggle("completed");
  }
});

// Add new item - it automatically has working buttons
function addItem(text) {
  const li = document.createElement("li");
  li.innerHTML = `
    ${text}
    <button class="toggle">Toggle</button>
    <button class="delete">Delete</button>
  `;
  list.appendChild(li);
}

Using closest() for Nested Structures

When the clicked element might be inside the target:

// HTML: <button class="card"><span class="icon">×</span> Delete</button>

container.addEventListener("click", (event) => {
  // event.target might be the span, not the button
  const button = event.target.closest(".card");
  if (button) {
    handleCardClick(button);
  }
});

The closest() method finds the nearest ancestor (or self) matching the selector.