JavaScript Event Propagation

Feb 25, 2022

7 min read

JavaScript Event Propagation: Bubbling, Capturing, and Delegation

Have you ever clicked a button inside a div, and both the button's and the div's click handlers fired? That's event propagation in action.

When an event happens on a DOM element, it doesn't just stay on that element. It travels through the DOM tree in a specific order. Understanding this order is key to writing clean, bug-free event handling code.

What is Event Propagation?

Event Propagation determines the order in which DOM elements receive an event. Consider this HTML:

<div id="grandparent">
  <div id="parent">
    <button id="child">Click Me</button>
  </div>
</div>

When the button is clicked, all three elements technically "contain" that click. So which element's event listener fires first? The grandparent, the parent, or the button?

That depends on the propagation mode. There are two: Bubbling and Capturing.

Event Bubbling

Bubbling is the default behavior. The event starts at the innermost element (the one you actually clicked) and then propagates outward to its ancestors, all the way up to the window.

Think of it like a bubble rising in water. It starts at the bottom and floats up.

const grandparent = document.getElementById("grandparent");
const parent = document.getElementById("parent");
const child = document.getElementById("child");

grandparent.addEventListener("click", () => {
  console.log("Grandparent clicked");
});

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

child.addEventListener("click", () => {
  console.log("Child clicked");
});

When you click the button, the output is:

Child clicked
Parent clicked
Grandparent clicked

The event fires on the button first, then bubbles up to parent, then to grandparent.

Event Capturing (Event Trickling)

Capturing is the opposite of bubbling. The event starts at the outermost ancestor and travels inward to the target element. It is also known as "event trickling" because the event trickles down from the top.

To use capturing, pass { capture: true } as the third argument to addEventListener:

grandparent.addEventListener("click", () => {
  console.log("Grandparent clicked (capture)");
}, { capture: true });

parent.addEventListener("click", () => {
  console.log("Parent clicked (capture)");
}, { capture: true });

child.addEventListener("click", () => {
  console.log("Child clicked (capture)");
}, { capture: true });

Now when you click the button, the output is:

Grandparent clicked (capture)
Parent clicked (capture)
Child clicked (capture)

The event fires on the outermost element first and works its way down to the target.

Mixing Bubbling and Capturing

What happens when some listeners use capturing and others use bubbling? The capturing phase always runs first (top to bottom), then the bubbling phase runs (bottom to top).

grandparent.addEventListener("click", () => {
  console.log("Grandparent - Capture");
}, { capture: true });

parent.addEventListener("click", () => {
  console.log("Parent - Bubble");
});

child.addEventListener("click", () => {
  console.log("Child - Bubble");
});

grandparent.addEventListener("click", () => {
  console.log("Grandparent - Bubble");
});

Clicking the button outputs:

Grandparent - Capture
Child - Bubble
Parent - Bubble
Grandparent - Bubble

The full lifecycle of an event is: Capture phase (top down) -> Target phase -> Bubble phase (bottom up).

Stopping Event Propagation

Sometimes you don't want the event to travel any further. You can stop it using event.stopPropagation().

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

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

Now clicking the button only outputs:

Child clicked

The event stops at the child and never reaches the parent or grandparent. This works in both capturing and bubbling phases.

⚠️

Use stopPropagation() only when you have a strong reason as it can break event delegation and make debugging harder.

Running Events Once

If you want an event listener to fire only one time, use the once option:

const subscribeBtn = document.getElementById("subscribe");

subscribeBtn.addEventListener("click", () => {
  console.log("Subscribed!");
}, { once: true });

// First click: "Subscribed!"
// Second click: nothing happens

After the first click, the listener is automatically removed. This is useful for things like one-time animations, form submissions, or tutorial tooltips.

Event Delegation

Here's where things get practical. Imagine you have a list of items and you want to handle clicks on each one:

<ul id="todo-list">
  <li class="todo-item">Buy groceries</li>
  <li class="todo-item">Walk the dog</li>
  <li class="todo-item">Read a book</li>
  <!-- more items... -->
</ul>

The Naive Approach

You could attach an event listener to every single li:

const items = document.getElementsByClassName("todo-item");
for (const item of items) {
  item.addEventListener("click", () => {
    console.log("Clicked:", item.textContent);
  });
}

This works, but it has two problems:

  • New items are missed. If you dynamically add a new li to the list, it won't have an event listener. You'd have to manually attach one every time.
  • Memory usage. Each event listener's callback creates a closure. With hundreds of items, that's hundreds of closures sitting in memory, slowing down your page.

The Better Approach: Event Delegation

Instead of listening on each child, attach a single listener to the parent and use event.target to figure out which child was clicked. This works because of bubbling: the click event on the li bubbles up to the ul.

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

todoList.addEventListener("click", (event) => {
  if (event.target.classList.contains("todo-item")) {
    console.log("Clicked:", event.target.textContent);
  }
});

Now it doesn't matter how many items are in the list. One listener handles them all. And any new items added dynamically are automatically covered.

How to Set Up Event Delegation

Three steps:

  • Find the common parent of the elements you want to monitor.
  • Attach one event listener to that parent.
  • Use event.target to check which child element triggered the event.

A More Complete Example

Here's a todo list where you can add new items and they automatically work with the click handler:

const todoList = document.getElementById("todo-list");
const addButton = document.getElementById("add-todo");
const input = document.getElementById("todo-input");

// One listener handles all current and future items
todoList.addEventListener("click", (event) => {
  if (event.target.classList.contains("todo-item")) {
    event.target.classList.toggle("completed");
    console.log("Toggled:", event.target.textContent);
  }
});

// New items work without adding extra listeners
addButton.addEventListener("click", () => {
  const li = document.createElement("li");
  li.className = "todo-item";
  li.textContent = input.value;
  todoList.appendChild(li);
  input.value = "";
});

Removing Event Listeners

We should always remove event listeners when they are no longer needed. Event listeners consume memory. The callback function that is passed to addEventListener creates a closure, and closures hold references to their surrounding scope. These are not garbage collected as long as the listener is attached.

If you're adding and removing DOM elements frequently (like in a single-page app), always clean up event listeners when elements are removed. Otherwise, you'll have memory leaks.

function handleClick() {
  console.log("Clicked!");
}

// Add
button.addEventListener("click", handleClick);

// Remove when no longer needed
button.removeEventListener("click", handleClick);
ℹ️

To remove an event listener, you need a reference to the original function. Anonymous functions (inline arrow functions) can't be removed with removeEventListener. Always use named functions if you plan to remove the listener later.

Pros and Cons of Event Delegation

Pros:

  • Saves memory. One listener instead of hundreds. Fewer closures, better performance.
  • Handles dynamic elements. New elements added to the DOM are automatically covered without extra setup.
  • Less code. No need to loop through elements and attach individual listeners.

Cons:

  • Not all events bubble. Events like blur, focus, scroll, and resize don't bubble. Event delegation won't work for these.
  • stopPropagation can break it. If a child element calls stopPropagation(), the event never reaches the parent, and your delegated handler won't fire.

Wrapping Up

Event propagation is one of those concepts that trips up a lot of developers, but once you see the full picture, it clicks. Events travel in two phases, capturing (top down) and bubbling (bottom up). By default, your listeners respond during the bubbling phase. And event delegation uses this bubbling behavior to handle events efficiently with a single listener on a parent element.

Until next time, happy coding!