Chiamaka Ikeanyi I craft scalable and accessible frontend solutions with great user experience. Currently, I write about accessibility, performance, JavaScript, workflow, and tooling.

Deep dive into JavaScript event bubbling and capturing

6 min read 1876

Deep Dive Event Bubbling Capturing Javascript

If an element and its parent have an event handler for the same event, which element will fire first when triggered?

Propagating events in JavaScript using bubbling and capturing provides developers with an answer to this question. In this article, we’ll learn how event bubbling and capturing work, compare different methods for accessing event properties, and run through a few different examples and use cases.

Let’s get started!

Event Capturing Bubbling Sequence Diagram
Event bubbling and capturing sequence.

What is event capturing?

In event capturing, which is also known as trickling, the outer event handler fires before the specific handler fires. For example, the event on the div triggers before the event on the button:

Event capturing hierarchy = document → html → body → parent → child

Capturing has a higher priority than bubbling, meaning that capturing event handlers are executed before bubbling event handlers, as shown by the phases of event propagation:

  1. Capturing phase : the event moves down towards the element
  2. Target phase:  the event reaches the target element
  3. Bubbling phase:  the event bubbles up from the element

What is event bubbling?

Event bubbling follows the opposite order as event capturing. An event propagates from a child HTML element, then moves up the DOM hierarchy to its parent elements:

Event bubbling hierarchy = child → parent → body → html → document

Listening to propagation events

We can listen to these propagation events using the addEventListener() method, which is appended to HTML nodes. It accepts three arguments: an event name, a callback function, and an optional capture value, which is set to false by default:

element.addEventListener(event, handler, false)

Capture value is empty

Let’s review what will happen if a user clicks on the button when the capturing value is left blank:

element.addEventListener(event, handler)

The click event begins in the capturing phase. It searches the parent elements of the target for any event with the event handler. It will not find any event handler for the capturing phase.

Next, it trickles down to the target. Once all events for the capturing phase are executed, the event moves to its bubbling phase. It executes any event handler set on the target element. It propagates up again, searching through the target’s parent elements for any event handler for the bubbling phase. Now, the event cycle is complete.

We made a custom demo for .
No really. Click here to check it out.

Capture value is true

Let’s consider what happens if the capturing value is set to true:

element.addEventListener(event, handler, true)

The code snippet above will follow the same order as when the value was empty. The key difference is that the event handler will execute any event handler it finds before trickling down to the target.

Accessing event object properties

There are differences in the methods used by handlers to access event object properties:

  • event.target: refers to the DOM element that triggered the event
  • event.eventPhase: returns the current phase of event propagation (capturing: 1, target: 2, bubbling: 3)
  • event.currentTarget: refers to the DOM element that handles the event

Note that if an event listener is attached to the parent, but event propagation is stopped by the child, event.currentTarget refers to the DOM element that stopped the propagation.

Event bubbling structure

Now that we understand how event bubbling and capturing work, let’s try an example! Let’s say we have the following DOM structure and the following event listener, respectively:

<button class="cta_button">Click me</button>
document 
.querySelector('.cta_button') 
.addEventListener('click', function(event) { 
  console.info(`Click event fired on ${this.nodeName}`);
 });

Click event fired on BUTTON would be logged to the console.

Nested DOM structure

Let’s see what happens when the DOM structure is nested and uses the same event listener attached to the parent element:

<div class="cta_container">
    <button class="cta_button">Watch me bubble</button>
</div>
document
.querySelector('.cta_container')
.addEventListener('click', function(event) {
  console.info(`Click event fired on ${this.nodeName}`);
});

In the code snippet above, we set a click event listener on the div, the parent element of the button. When clicked, it logs the type of event fired and the element it is fired on.

When users click the Watch me bubble button, the event is directed to the button. If an event handler is set for the button, the event is triggered. Otherwise, the event bubbles, or propagates, to the parent div, and a click event is fired on the parent. If the event is not handled, the process continues to the next parent at the outer bound until it eventually reaches the document object.

Even though you clicked on the button, the information logged to the console is Click event fired on DIV.

Attach event listener to button

What happens when we also attach an event listener to the button?

<div class="cta_container">
    <button class="cta_button">Watch me bubble</button>
</div>
document
.querySelector('.cta_container')
.addEventListener('click', function(event) {
    console.info(Click event fired on ${this.nodeName}`);
});

The output becomes Click event fired on BUTTON and Click event fired on DIV.

As you can see, the event bubbled to the parent. You can use the event.bubbles property to ascertain whether an event bubbles:

document
.querySelector('.cta_button')
.addEventListener('click', function(event) {
    console.info(
  Click event fired on ${this.nodeName}. Does it bubble? ${event.bubbles}`
);
});

Stopping the propagation

An event on a DOM element propagates to all of its parent elements unless it is stopped. Although there is usually no need to prevent bubbling, it can be useful in certain cases. For example, stopping the propagation can prevent event handlers from interfering with each other.

Consider handling drag-and-drop using mousemove and mouseup events. Stopping the propagation may prevent browser bugs that arise as a result of users randomly moving the mouse.

Calling event.stopPropagation() on the child element prevents it from bubbling to the parent element:

document.querySelector('.cta_button').addEventListener('click', event => {
   event.stopPropagation();
   // ...
});

Let’s stop the propagation for our example from the previous section where we clicked a button:

<div class="cta_container">
  <button class="cta_button">Watch the bubble stop</button>
</div>

Let’s add the event listeners:

document
.querySelector('.cta_container')
.addEventListener('click', function(event) {
   console.info(`Click event fired on ${this.nodeName}`);
});

document
.querySelector('.cta_button')
.addEventListener('click', function(event) {
    event.stopPropagation();
  console.info(`Click event fired on ${this.nodeName}`);
});

The event was prevented from bubbling using event.stopPropagation(). The output becomes Click event fired on BUTTON.

Prevent browser default

Let’s say you want to allow the propagation to continue, but you want to prevent the browser from performing its default action if there is no listener handling the event. You can use event.preventDefault():

document.querySelector('.cta_button')
.addEventListener('click', event => {
    event.preventDefault();
  // ...
});

Event bubbling use case

Let’s apply what we’ve covered and create a shopping list app that strikes an item once you purchase it. In this scenario, adding individual event listeners won’t be feasible because you may decide to add a new item in the future.

Instead, you need to attach the event listener to the parent element of the list. The event listener will handle the click event from the children through event bubbling.

In the code snippet below, we list our shopping items:

<ul class="list">
<li class="item">MacBook Pro</li>
<li class="item">Sony a6400 camera</li>
<li class="item">Boya universal cardioid microphone</li>
<li class="item">Light ring</li>
</ul>

Add an event listener attached to <ul>, as seen below:

document.querySelector('.list').addEventListener('click', event => {
  event.target.classList.toggle('purchased');
});

You can view this code in the CodePen below:

See the Pen
Event Bubbling
by Chiamaka Ikeanyi (@chiamakaikeanyi)
on CodePen.

Event capturing structure

During event delegation, where event bubbling is not supported, event capturing becomes especially beneficial for attaching events to dynamic content. For example, you may need to handle events like focus and blur for which bubbling is not supported.

To catch an event on the capturing phase, you need to set the useCapture option to true. Remember, by default, it is set to false:

element.addEventListener(event, handler, true)

Let’s consider a nested DOM structure:

<div class="cta_container">
    <button class="cta_button">Watch me capture</button>
</div>

We’ll set the useCapture option of the parent element to true:

document.querySelector('.cta_container').addEventListener(
'click', function(event) {
   console.info(Click event fired on ${this.nodeName}`);
},
true
);

document
.querySelector('.cta_button')
.addEventListener('click', function(event) {
   console.info(`Click event fired on ${this.nodeName}`);
});

Contrary to what we get using bubbling, the output is Click event fired on DIV and Click event fired on BUTTON.

Event capturing use case

Let’s continue our example from earlier when we built a shopping list. If you wanted to add an input field to the shopping list that enables you to set a budget for each item, the event listener attached to the parent would not apply to those input fields.

Let’s take the code for our shopping list and an event listener:

<h1 class="title">Shopping List</h1>
<ul class="list">
          <li class="item">
              MacBook Pro 
              <input class="budget" type="number" min=1>
          </li>
          <li class="item">
              Logitech MX Keys 
              <input class="budget" type="number" min=1>
          </li>
          <li class="item">
              Sony a6400 camera 
              <input class="budget" type="number" min=1>
          </li>
          <li class="item">
              Boya universal cardioid microphone 
             <input class="budget" type="number" min=1>
          </li>
          <li class="item">
              Light ring
              <input class="budget" type="number" min=1>
          </li>   
</ul>
document.querySelector(".list").addEventListener("focus", function (event) {
      console.info(${event.type} event fired on ${this.nodeName}`);
        event.target.style.background = "#eee";

       console.log("target:", event.target);
        console.log("currentTarget:", event.currentTarget);
        console.log("eventPhase:", event.eventPhase);
});

When you focus the cursor on any input field, nothing happens. However, when you set the useCapture option to true, you’ll achieve the desired result:

document.querySelector(".list").addEventListener("focus", function (event) {
      console.info(`${event.type} event fired on ${this.nodeName}`);
        event.target.style.background = "#eee";

        console.log("target:", event.target);
        console.log("currentTarget:", event.currentTarget);
        console.log("eventPhase:", event.eventPhase);
}, true);

See the Pen
Event Capturing
by Chiamaka Ikeanyi (@chiamakaikeanyi)
on CodePen.

You can view this list in the CodePen above.

Conclusion

A strong understanding of event bubbling and capturing is essential for handling user events in JavaScript. In this tutorial, we learned how event propagation works in JavaScript, following the sequence of capturing, target phase, and bubbling.

Note that bubbling always propagates from a child element to the parent, while capturing propagates from the parent element to the child. To remember the propagation order, you can think of “bubble up and trickle down.”

I hope you enjoyed this tutorial!

: Debug JavaScript errors easier by understanding the context

Debugging code is always a tedious task. But the more you understand your errors the easier it is to fix them.

LogRocket allows you to understand these errors in new and unique ways. Our frontend monitoring solution tracks user engagement with your JavaScript frontends to give you the ability to find out exactly what the user did that led to an error.

LogRocket records console logs, page load times, stacktraces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!

.
Chiamaka Ikeanyi I craft scalable and accessible frontend solutions with great user experience. Currently, I write about accessibility, performance, JavaScript, workflow, and tooling.

Leave a Reply