Skip to content

Event Bubbling and Capturing: Deep Understanding of Event Propagation Mechanism

Events Are Not Isolated

When you click a button on a page, you're not just clicking that button. From the browser's perspective, you're also clicking the container that holds the button, the container's parent element, the body element, and even the entire document and window objects.

This might sound strange, but think about it: like clapping your hands in a room, the sound reaches not only the person in front of you but every corner of the room. Events are the same—they don't stay confined to the element that triggered them but propagate along the DOM tree.

This propagation mechanism is Event Bubbling and Event Capturing. Understanding how they work is crucial for writing efficient, elegant event handling code.

The Three Phases of Event Flow

When an event occurs, it goes through three phases:

1. Capturing Phase

The event starts from the outermost window object and propagates down along the DOM tree, passing through each parent element layer by layer until it reaches the target element.

This process is like a drop of water falling from the top of a tree, passing through each branch layer by layer, finally reaching the target leaf.

2. Target Phase

The event reaches the element that actually triggered the event—the element the user truly clicked or interacted with.

3. Bubbling Phase

The event starts from the target element and bubbles up along the DOM tree, passing through each parent element layer by layer until it reaches the window object.

This process is like bubbles in water rising from the bottom to the surface.

Complete Example

html
<div id="grandparent">
  <div id="parent">
    <button id="child">Click me</button>
  </div>
</div>
javascript
const grandparent = document.getElementById("grandparent");
const parent = document.getElementById("parent");
const child = document.getElementById("child");

// Capturing phase listeners (third parameter is true)
window.addEventListener("click", () => console.log("window capture"), true);
document.addEventListener("click", () => console.log("document capture"), true);
grandparent.addEventListener(
  "click",
  () => console.log("grandparent capture"),
  true
);
parent.addEventListener("click", () => console.log("parent capture"), true);
child.addEventListener("click", () => console.log("child capture"), true);

// Bubbling phase listeners (third parameter is false or omitted)
child.addEventListener("click", () => console.log("child bubble"));
parent.addEventListener("click", () => console.log("parent bubble"));
grandparent.addEventListener("click", () => console.log("grandparent bubble"));
document.addEventListener("click", () => console.log("document bubble"));
window.addEventListener("click", () => console.log("window bubble"));

When you click the button, the console outputs:

window capture
document capture
grandparent capture
parent capture
child capture
child bubble
parent bubble
grandparent bubble
document bubble
window bubble

The event propagation path is:

Capturing phase: window → document → grandparent → parent → child
Target phase: child
Bubbling phase: child → parent → grandparent → document → window

Default is Bubbling Phase

In daily development, the vast majority of the time we listen to events in the bubbling phase. In fact, the third parameter of addEventListener defaults to false, meaning it executes in the bubbling phase:

javascript
// These three forms have the same effect
element.addEventListener("click", handler);
element.addEventListener("click", handler, false);
element.addEventListener("click", handler, { capture: false });

Only when you need to intercept events in the capturing phase do you set the third parameter to true:

javascript
// Execute in capturing phase
element.addEventListener("click", handler, true);
element.addEventListener("click", handler, { capture: true });

Why Bubbling is Needed

The event bubbling mechanism may seem complex, but it brings great convenience. The most typical application is Event Delegation.

Problems Without Event Delegation

Suppose you have a list with 100 list items, and each item needs to respond to click events:

javascript
// ❌ Inefficient approach: Add listener to each list item
const items = document.querySelectorAll(".list-item");

items.forEach((item) => {
  item.addEventListener("click", function (event) {
    console.log("Clicked:", this.textContent);
    this.classList.toggle("selected");
  });
});

// Problems:
// 1. Created 100 listeners, consuming memory
// 2. If dynamically adding new list items, need to bind events again
// 3. When removing list items, listeners may cause memory leaks

Advantages of Using Event Delegation

Using event bubbling, you can add only one listener on the parent element:

javascript
// ✅ Efficient approach: Event delegation
const list = document.querySelector(".list");

list.addEventListener("click", function (event) {
  // Check if a list item was clicked
  if (event.target.matches(".list-item")) {
    console.log("Clicked:", event.target.textContent);
    event.target.classList.toggle("selected");
  }
});

// Advantages:
// 1. Only 1 listener, saves memory
// 2. Dynamically added list items automatically support clicks
// 3. No need to clean up listeners when removing list items

The event bubbles from the list item to the list element, where we intercept it and determine which child element triggered the event.

More Complex Delegation Scenarios

html
<ul class="todo-list">
  <li class="todo-item">
    <input type="checkbox" class="toggle" />
    <span class="text">Learn event bubbling</span>
    <button class="delete">Delete</button>
    <button class="edit">Edit</button>
  </li>
  <!-- More list items -->
</ul>
javascript
const todoList = document.querySelector(".todo-list");

todoList.addEventListener("click", function (event) {
  const target = event.target;

  // Click delete button
  if (target.matches(".delete")) {
    const item = target.closest(".todo-item");
    deleteItem(item);
  }
  // Click edit button
  else if (target.matches(".edit")) {
    const item = target.closest(".todo-item");
    editItem(item);
  }
  // Click text
  else if (target.matches(".text")) {
    const item = target.closest(".todo-item");
    viewDetail(item);
  }
});

// Checkboxes use change event
todoList.addEventListener("change", function (event) {
  if (event.target.matches(".toggle")) {
    const item = event.target.closest(".todo-item");
    toggleItem(item);
  }
});

This pattern handles all interactions of all elements in the list with just two listeners, no matter how many items the list has.

Stopping Event Propagation

Sometimes we don't want events to continue propagating. We can use several methods to control this.

stopPropagation() - Stop Propagation

The stopPropagation() method prevents the event from continuing to propagate but doesn't prevent other listeners on the same element from executing:

javascript
child.addEventListener("click", function (event) {
  console.log("child click");
  event.stopPropagation(); // Stop bubbling
});

parent.addEventListener("click", function () {
  console.log("parent click"); // Won't execute
});

Practical application scenarios:

javascript
// Modal: Click background to close, click content doesn't close
const modal = document.querySelector(".modal");
const modalContent = modal.querySelector(".modal-content");

modal.addEventListener("click", function () {
  closeModal();
});

modalContent.addEventListener("click", function (event) {
  event.stopPropagation(); // Prevent bubbling to modal
});

// Dropdown: Click button to toggle, click document to close
const dropdown = document.querySelector(".dropdown");
const dropdownButton = dropdown.querySelector(".button");
const dropdownMenu = dropdown.querySelector(".menu");

dropdownButton.addEventListener("click", function (event) {
  event.stopPropagation();
  toggleDropdown();
});

document.addEventListener("click", function () {
  closeAllDropdowns();
});

stopImmediatePropagation() - Stop Immediately

stopImmediatePropagation() not only prevents event propagation but also prevents subsequent listeners on the same element from executing:

javascript
element.addEventListener("click", function (event) {
  console.log("First listener");
  event.stopImmediatePropagation();
});

element.addEventListener("click", function () {
  console.log("Second listener"); // Won't execute
});

element.addEventListener("click", function () {
  console.log("Third listener"); // Won't execute
});

// After clicking, only outputs: First listener

Compare with stopPropagation():

javascript
element.addEventListener("click", function (event) {
  console.log("First listener");
  event.stopPropagation(); // Only stops propagation
});

element.addEventListener("click", function () {
  console.log("Second listener"); // Will execute
});

// After clicking, outputs:
// First listener
// Second listener

Stopping in Capturing Phase

You can also stop propagation in the capturing phase:

javascript
parent.addEventListener(
  "click",
  function (event) {
    console.log("parent capture");
    event.stopPropagation(); // Prevent further downward capture
  },
  true
);

child.addEventListener("click", function () {
  console.log("child click"); // Won't execute
});

// Clicking child only outputs: parent capture

This feature can be used to implement "global interception":

javascript
// Disable all clicks in an area
const disabledArea = document.querySelector(".disabled-area");

disabledArea.addEventListener(
  "click",
  function (event) {
    event.stopPropagation();
    event.preventDefault();
    showMessage("This area is disabled");
  },
  true
); // Intercept in capturing phase

// All elements within the area cannot respond to clicks

Non-bubbling Events

Not all events bubble. The following events do not bubble:

Focus Events

javascript
// ❌ Don't bubble
element.addEventListener("focus", handler);
element.addEventListener("blur", handler);

// ✅ Bubbling versions
element.addEventListener("focusin", handler);
element.addEventListener("focusout", handler);

Example:

javascript
// Use focusin for form-level focus management
const form = document.querySelector("form");

form.addEventListener("focusin", function (event) {
  console.log("Element in form got focus:", event.target);
  event.target.parentElement.classList.add("focused");
});

form.addEventListener("focusout", function (event) {
  console.log("Element in form lost focus:", event.target);
  event.target.parentElement.classList.remove("focused");
});

Mouse Enter/Leave Events

javascript
// ❌ Don't bubble
element.addEventListener("mouseenter", handler);
element.addEventListener("mouseleave", handler);

// ✅ Bubbling versions
element.addEventListener("mouseover", handler);
element.addEventListener("mouseout", handler);

Difference example:

html
<div id="parent" style="padding: 20px; background: lightblue;">
  Parent
  <div id="child" style="padding: 10px; background: lightcoral;">Child</div>
</div>
javascript
const parent = document.getElementById("parent");

// mouseenter: Only triggers once when entering parent
parent.addEventListener("mouseenter", () => {
  console.log("Enter parent");
});

// mouseover: Triggers when entering parent, and bubbles from child to parent
parent.addEventListener("mouseover", (event) => {
  console.log("over parent, from:", event.target.id);
});

// Move mouse into parent, then into child:
// mouseenter: Only outputs "Enter parent" once
// mouseover: Outputs "over parent, from: parent" and "over parent, from: child"

Resource Loading Events

javascript
// The following events don't bubble
img.addEventListener("load", handler);
img.addEventListener("error", handler);
script.addEventListener("load", handler);

Other Non-bubbling Events

  • scroll
  • resize (on window)
  • Media-related events (like play, pause)

Practical Applications of Capturing Phase

Although we mostly use the bubbling phase, the capturing phase is useful in certain scenarios.

Global Event Interception

javascript
// Intercept all clicks in capturing phase to implement "freeze" effect
const overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.inset = "0";
overlay.style.zIndex = "9999";
overlay.style.backgroundColor = "rgba(0, 0, 0, 0.5)";

function freezePage() {
  document.body.appendChild(overlay);

  // Intercept all interactions in capturing phase
  overlay.addEventListener("click", handleBlockedClick, true);
  overlay.addEventListener("keydown", handleBlockedKey, true);
}

function handleBlockedClick(event) {
  event.stopPropagation();
  event.preventDefault();
  console.log("Page frozen");
}

function handleBlockedKey(event) {
  event.stopPropagation();
  event.preventDefault();
}

function unfreezePage() {
  overlay.remove();
}

Form Validation Interception

javascript
const form = document.querySelector("form");

// Validate in capturing phase
form.addEventListener(
  "submit",
  function (event) {
    const isValid = validateForm(this);

    if (!isValid) {
      event.stopPropagation(); // Prevent other handlers
      event.preventDefault(); // Prevent submission
      showErrors();
    }
  },
  true
); // Capturing phase

// This handler only executes if validation passes
form.addEventListener("submit", function (event) {
  console.log("Form validation passed, preparing to submit");
  submitFormViaAjax(this);
});

Debugging and Logging

javascript
// Log all events in capturing phase
document.addEventListener(
  "click",
  function (event) {
    console.log("Capture:", {
      target: event.target.tagName,
      currentTarget: event.currentTarget,
      phase: event.eventPhase,
      timestamp: event.timeStamp,
    });
  },
  true
);

target vs currentTarget

Understanding the difference between event.target and event.currentTarget is crucial for using event delegation.

html
<div id="outer">
  <div id="middle">
    <button id="inner">
      <span id="text">Click</span>
    </button>
  </div>
</div>
javascript
const outer = document.getElementById("outer");

outer.addEventListener("click", function (event) {
  console.log("target:", event.target.id);
  // Clicking span: "text"
  // Clicking button: "inner"
  // Clicking middle: "middle"

  console.log("currentTarget:", event.currentTarget.id);
  // Always "outer" (element with listener attached)

  console.log("this:", this.id);
  // Always "outer" (same as currentTarget)
});

Practical application:

javascript
// Use target to determine which specific element was clicked
todoList.addEventListener("click", function (event) {
  const target = event.target;

  if (target.matches(".delete-btn")) {
    deleteItem(target.closest(".todo-item"));
  }
});

// Use currentTarget to access the element where the listener is attached
todoList.addEventListener("click", function (event) {
  console.log("List was clicked");
  console.log("List element:", event.currentTarget);
  console.log("Actually clicked:", event.target);
});

closest() Method with Event Delegation

The closest() method finds the closest matching ancestor element, working perfectly with event delegation:

html
<div class="card">
  <div class="card-header">
    <h3>Title</h3>
    <button class="close-btn">
      <span class="icon">×</span>
    </button>
  </div>
  <div class="card-body">Content</div>
</div>
javascript
const container = document.querySelector(".container");

container.addEventListener("click", function (event) {
  // Click close button or elements within it
  const closeBtn = event.target.closest(".close-btn");
  if (closeBtn) {
    const card = closeBtn.closest(".card");
    card.remove();
    return;
  }

  // Click card header
  const cardHeader = event.target.closest(".card-header");
  if (cardHeader) {
    cardHeader.classList.toggle("expanded");
    return;
  }
});

Advanced Event Delegation Patterns

Dynamically Adding Elements

javascript
const taskList = document.querySelector(".task-list");

// Event delegation
taskList.addEventListener("click", function (event) {
  if (event.target.matches(".task-item")) {
    toggleTask(event.target);
  }
});

// Dynamically add new tasks, automatically support clicks
function addTask(text) {
  const task = document.createElement("div");
  task.className = "task-item";
  task.textContent = text;
  taskList.appendChild(task); // No need to bind events
}

addTask("New task"); // Automatically supports clicks

Delegating Multiple Interactions

javascript
const gallery = document.querySelector(".gallery");

// Unified event delegation handler
gallery.addEventListener("click", function (event) {
  const target = event.target;

  // Click image
  if (target.matches(".gallery-image")) {
    openLightbox(target);
  }
  // Click delete button
  else if (target.matches(".delete-btn")) {
    event.stopPropagation(); // Don't trigger image click
    deleteImage(target.closest(".gallery-item"));
  }
  // Click favorite button
  else if (target.matches(".favorite-btn")) {
    event.stopPropagation();
    toggleFavorite(target.closest(".gallery-item"));
  }
});

// Double-click to rename
gallery.addEventListener("dblclick", function (event) {
  if (event.target.matches(".gallery-title")) {
    renameImage(event.target.closest(".gallery-item"));
  }
});

// Right-click menu
gallery.addEventListener("contextmenu", function (event) {
  if (event.target.matches(".gallery-image")) {
    event.preventDefault();
    showContextMenu(event.target, event.clientX, event.clientY);
  }
});

Performance Optimization: Throttling and Debouncing

javascript
function throttle(func, delay) {
  let lastTime = 0;
  return function (...args) {
    const now = Date.now();
    if (now - lastTime >= delay) {
      func.apply(this, args);
      lastTime = now;
    }
  };
}

// Throttle scroll events
window.addEventListener(
  "scroll",
  throttle(function (event) {
    updateScrollPosition();
  }, 100)
); // Execute at most once every 100ms

Common Pitfalls and Best Practices

Avoid Overusing stopPropagation

javascript
// ❌ Overuse can break other functionality
button.addEventListener("click", function (event) {
  event.stopPropagation(); // Might block analytics code, debugging tools, etc.
  handleClick();
});

// ✅ Use only when necessary
modal.addEventListener("click", function (event) {
  if (event.target === event.currentTarget) {
    // Only close when clicking background
    closeModal();
  }
});

Correctly Distinguish preventDefault and stopPropagation

javascript
link.addEventListener("click", function (event) {
  // preventDefault: Prevent default behavior (navigation)
  event.preventDefault();

  // stopPropagation: Prevent bubbling
  // Usually don't need both at the same time
});

// Most cases only need preventDefault
form.addEventListener("submit", function (event) {
  event.preventDefault(); // Prevent form submission
  // Don't need stopPropagation, let event continue bubbling
  submitFormViaAjax(this);
});

Pay Attention to Event Delegation Matching Precision

javascript
// ❌ Might match wrong elements
list.addEventListener("click", function (event) {
  if (event.target.tagName === "LI") {
    // If LI has other elements inside, this check might be inaccurate
    handleItem(event.target);
  }
});

// ✅ Use more precise selectors
list.addEventListener("click", function (event) {
  const item = event.target.closest(".list-item");
  if (item && list.contains(item)) {
    handleItem(item);
  }
});

Summary

Event bubbling and capturing are core mechanisms of the JavaScript event system. Through this chapter, you should master:

  1. Three Event Flow Phases: Capture, target, bubble
  2. Event Delegation Pattern: Use bubbling to reduce listener count
  3. Control Propagation: stopPropagation() and stopImmediatePropagation()
  4. target vs currentTarget: Understand the difference between actual target and current target
  5. Practical Applications: Dynamic element handling, multiple interaction delegation, performance optimization