Navigate back to homepage - Caricature image of Ryan Tyrrell

Hi, I'm Ryan!

I build web sites & apps for entertainment brands

Demystifying event bubbling and propagation

What is event bubbling and propagation?

When building interactive software applications, we need to do respond to user input. This could mean starting audio playback when the user presses a "play" button in our audio player, or adding a comment to a video when our user hits the "post" button. I'm regularly asked to take a look at software when things do not work as expected, and often the problems are related to event bubbling and propagation issues when implementing features like this. A "click" event on a button can respond in different ways depending on when it was clicked, the order the code was executed in and what other things are happening in the software at that time. This blog post aims to demystify the terminology around events in web applications and hopefully help developers solve similar problems when they come across them in the future.

Explain it to me like I'm an eight year old

An event is essentially "something happening" in a software application. For example, a click on a button / a form being submitted / the page scrolling down or up. In a web application, events are the things that cause actions in the browser. When a user clicks on a link to another web site, the web browser responds to the "click event" by loading that web site. When a user hits the subscribe button in a newsletter sign-up form, the browser responds to the "submit event" that the form dispatches, and posts the email information in the form to a server.

As web developers, we have the ability to respond to these events using JavaScript. We can tell the browser not to perform the default functionality (such as navigating to another web site), and can change what happens altogether.

Examples

Check out the example below. Clicking the link will trigger a "click event" (an "event" of type "click"), which the browser will interpret to mean "load https://seatedly.com in the browser". This is the browsers default way of handling a click event on a link element.

<a href="https://seatedly.com">Click here to visit seatedly.com</a>

We can also respond to events that are triggered in the browser by adding an "event listener" to the element using JavaScript. We can tell the browser not to perform the default functionality by calling the "preventDefault" method on the event that is passed to our event listener.

<a data-js-clickable-link href="https://seatedly.com">Click here to visit seatedly.com</a>
<script>
    var clickableLinkElement = document.querySelector('[data-js-clickable-link]');
    var preventDefaultBehaviour = function(clickEvent){
        clickEvent.preventDefault();
    };
    clickableLinkElement.addEventListener('click', preventDefaultBehaviour);
</script>

In the example above, we are listening for click events on our link element and preventing the browser from navigating to the web site, which it would do by default. When the link is clicked, there's more functionality going on behind the scenes in that the click event is also applied to every element that the link is a child of, all the way up to the document itself. So if our <a> element was a child of a <p> element, within the <body> of the document, the click event would be triggered on the <a>, followed by the <p>, then the <body> and finally the document itself.

Below, we listen for click events on the link and the document, both of which are triggered when we click the link.

<a data-js-clickable-link href="https://seatedly.com">Click here to visit seatedly.com</a>
<p data-js-text-log></p>
<script>
    var clickableLinkElement = document.querySelector('[data-js-clickable-link]');
    var textLogElement = document.querySelector('[data-js-text-log]');
    var linkClickHandler = function(clickEvent){
        clickEvent.preventDefault();
        textLogElement.innerHTML += 'link was clicked<br>';
    };
    var documentClickHandler = function(clickEvent){
        textLogElement.innerHTML += 'document was clicked<br>';
    };
    clickableLinkElement.addEventListener('click', linkClickHandler);
    document.addEventListener('click', documentClickHandler);
</script>

The fact that our document has a click event triggered when we click our link element is a result of "event bubbling". The click event "bubbles up" through all elements that are a parent of the element that was clicked. This is known as event "propagation. Using JavaScript, we can control whether or not our events propagate to other elements. In the example below, we call the "stopPropagation" method on the click event within our link element's click handler.

<a data-js-clickable-link href="https://seatedly.com">Click here to visit seatedly.com</a>
<p data-js-text-log></p>
<script>
    var clickableLinkElement = document.querySelector('[data-js-clickable-link]');
    var textLogElement = document.querySelector('[data-js-text-log]');
    var linkClickHandler = function(clickEvent){
        clickEvent.preventDefault();
        clickEvent.stopPropagation();
        textLogElement.innerHTML += 'link was clicked<br>';
    };
    var documentClickHandler = function(clickEvent){
        textLogElement.innerHTML += 'document was clicked<br>';
    };
    document.addEventListener('click', documentClickHandler);
    clickableLinkElement.addEventListener('click', linkHandler);
</script>

Take a look what happens when we add a second event listener to our link element. Both listeners receive the click event and respond accordingly.

<a data-js-clickable-link href="https://seatedly.com">Click here to visit seatedly.com</a>
<p data-js-text-log></p>
<script>
    var clickableLinkElement = document.querySelector('[data-js-clickable-link]');
    var textLogElement = document.querySelector('[data-js-text-log]');
    var firstLinkClickHandler = function(clickEvent){
        clickEvent.preventDefault();
        clickEvent.stopPropagation();
        textLogElement.innerHTML += 'First link handler was triggered<br>';
    };
    var secondLinkClickHandler = function(clickEvent){
        clickEvent.preventDefault();
        clickEvent.stopPropagation();
        textLogElement.innerHTML += 'Second link handler was triggered<br>';
    };
    var documentClickHandler = function(clickEvent){
        textLogElement.innerHTML += 'document was clicked<br>';
    };
    document.addEventListener('click', documentClickHandler);
    clickableLinkElement.addEventListener('click', firstLinkClickHandler);
    clickableLinkElement.addEventListener('click', secondLinkClickHandler);
</script>

Because we have two event listeners attached to the same element, the order in which we "bind" these listeners affects how the code is executed. By reversing the last two lines of code in the example above, the second event listener is triggered before the first one:

<a data-js-clickable-link href="https://seatedly.com">Click here to visit seatedly.com</a>
<p data-js-text-log></p>
<script>
    var clickableLinkElement = document.querySelector('[data-js-clickable-link]');
    var textLogElement = document.querySelector('[data-js-text-log]');
    var firstLinkClickHandler = function(clickEvent){
        clickEvent.preventDefault();
        clickEvent.stopPropagation();
        textLogElement.innerHTML += 'First link handler was triggered<br>';
    };
    var secondLinkClickHandler = function(clickEvent){
        clickEvent.preventDefault();
        clickEvent.stopPropagation();
        textLogElement.innerHTML += 'Second link handler was triggered<br>';
    };
    var documentClickHandler = function(clickEvent){
        textLogElement.innerHTML += 'document was clicked<br>';
    };
    document.addEventListener('click', documentClickHandler);
    clickableLinkElement.addEventListener('click', secondLinkClickHandler);
    clickableLinkElement.addEventListener('click', firstLinkClickHandler);
</script>

Going back to the previous example - what if we only wanted to trigger the first event listener on our link element and stop the second listener from being triggered in some circumstances? An example here would be if you wanted to process some information in a form. When the button is clicked, you call a "validateForm" function first, then if everything looks good, the "sendFormData" function is called. You want the ability to prevent the second function from firing if the first function determines that the form data is invalid. Here, we need to make use of the "stopImmediatePropagation" method on the click event. In the example below, the second click event listener will only be triggered every other time that the link is clicked.

<a data-js-clickable-link href="https://seatedly.com">Click here to visit seatedly.com</a>
<p data-js-text-log></p>
<script>
    var shouldFireSecondListener = false;
    var clickableLinkElement = document.querySelector('[data-js-clickable-link]');
    var textLogElement = document.querySelector('[data-js-text-log]');
    var firstLinkClickHandler = function(clickEvent){
        clickEvent.preventDefault();
        clickEvent.stopPropagation();
        textLogElement.innerHTML += 'First link handler was triggered<br>';
        if(!shouldFireSecondListener){
            clickEvent.stopImmediatePropagation();
        }
        shouldFireSecondListener = !shouldFireSecondListener;
    };
    var secondLinkClickHandler = function(clickEvent){
        clickEvent.preventDefault();
        clickEvent.stopPropagation();
        textLogElement.innerHTML += 'Second link handler was triggered<br>';
    };
    var documentClickHandler = function(clickEvent){
        textLogElement.innerHTML += 'document was clicked<br>';
    };
    document.addEventListener('click', documentClickHandler);
    clickableLinkElement.addEventListener('click', secondLinkClickHandler);
    clickableLinkElement.addEventListener('click', firstLinkClickHandler);
</script>

There are actually two ways of listening for events, known as the "capture" and the "bubbling" phases. When an event occurs, such as a click on a link element, the click is "captured" by the document itself first in the capture phase. The event is then triggered on all child elements all the way down to the element that the event occurred on (the link element in this case). Once the event reaches this element, the event then "bubbles" its way back up trough all parent elements to the document.

As developers, we have control over which phase we bind our event to, using a third argument passed to the "addEventListener" method. By default, this value is set to the false boolean, which binds our event to the bubbling phase. When we set it to true, we tell our event to bind to the capture phase. This allows us to do things like prevent bubbling from happening at all, stopping propagation at the top level.

This is a bit confusing to write about without an example. Below, we bind six events in total - two on the document, two on the body element and two on the link element, each triggering an event in both the capture and the bubbling phases. When we click the link element, the event is "captured" by the top level event listener (document) and trickles its way down through the other elements, before bubbling its way back up to the topmost document level. In the example below, the order of events is:

  • Document click event is triggered in the capture phase
  • Body click event is triggered in the capture phase
  • Link click event is triggered in the capture phase
  • Link click event is triggered in the bubbling phase
  • Body click event is triggered in the bubbling phase
  • Document click event is triggered in the bubbling phase
<a data-js-clickable-link href="https://seatedly.com">Click here to visit seatedly.com</a>
<p data-js-text-log></p>
<script>
    var clickableLinkElement = document.querySelector('[data-js-clickable-link]');
    var textLogElement = document.querySelector('[data-js-text-log]');
    var captureDocumentHandler = function(clickEvent){
        textLogElement.innerHTML += 'CAPTURE PHASE - Document was clicked<br>';
    };
    var captureBodyHandler = function(clickEvent){
        textLogElement.innerHTML += 'CAPTURE PHASE - Body was clicked<br>';
    };
    var captureLinkHandler = function(clickEvent){
        clickEvent.preventDefault();
        textLogElement.innerHTML += 'CAPTURE PHASE - Link was clicked<br>';
    };
    var bubblingLinkHandler = function(clickEvent){
        textLogElement.innerHTML += 'BUBBLING PHASE - Link was clicked<br>';
    };
    var bubblingBodyHandler = function(clickEvent){
        textLogElement.innerHTML += 'BUBBLING PHASE - Body was clicked<br>';
    };
    var bubblingDocumentHandler = function(clickEvent){
        textLogElement.innerHTML += 'BUBBLING PHASE - Document was clicked<br>';
    };
    document.addEventListener('click', captureDocumentHandler, true);
    document.body.addEventListener('click', captureBodyHandler, true);
    clickableLinkElement.addEventListener('click', captureLinkHandler, true);
    clickableLinkElement.addEventListener('click', bubblingLinkHandler);
    document.body.addEventListener('click', bubblingBodyHandler);
    document.addEventListener('click', bubblingDocumentHandler);
</script>

You can stop event propagation in the capture phase too. In the example below, I stop immediate propagation in the body capture event listener. The order of event is as follows:

  • Document click event is triggered in the capture phase
  • Body click event is triggered in the capture phase
  • Body stops all further propagation down the element tree, meaning no other elements receive the event
  • Since neither the document nor body prevented default behaviour, the clicked link directs the user to the web site.
<a data-js-clickable-link href="https://seatedly.com">Click here to visit seatedly.com</a>
<p data-js-text-log></p>
<script>
    var clickableLinkElement = document.querySelector('[data-js-clickable-link]');
    var textLogElement = document.querySelector('[data-js-text-log]');
    var captureDocumentHandler = function(clickEvent){
        textLogElement.innerHTML += 'CAPTURE PHASE - Document was clicked<br>';
    };
    var captureBodyHandler = function(clickEvent){
        clickEvent.stopImmediatePropagation();
        textLogElement.innerHTML += 'CAPTURE PHASE - Body was clicked<br>';
    };
    var captureLinkHandler = function(clickEvent){
        clickEvent.preventDefault();
        textLogElement.innerHTML += 'CAPTURE PHASE - Link was clicked<br>';
    };
    var bubblingLinkHandler = function(clickEvent){
        textLogElement.innerHTML += 'BUBBLING PHASE - Link was clicked<br>';
    };
    var bubblingBodyHandler = function(clickEvent){
        textLogElement.innerHTML += 'BUBBLING PHASE - Body was clicked<br>';
    };
    var bubblingDocumentHandler = function(clickEvent){
        textLogElement.innerHTML += 'BUBBLING PHASE - Document was clicked<br>';
    };
    document.addEventListener('click', captureDocumentHandler, true);
    document.body.addEventListener('click', captureBodyHandler, true);
    clickableLinkElement.addEventListener('click', captureLinkHandler, true);
    clickableLinkElement.addEventListener('click', bubblingLinkHandler);
    document.body.addEventListener('click', bubblingBodyHandler);
    document.addEventListener('click', bubblingDocumentHandler);
</script>

If we call preventDefault on the event in the capture phase on the body element, the link will not redirect the user:

<a data-js-clickable-link href="https://seatedly.com">Click here to visit seatedly.com</a>
<p data-js-text-log></p>
<script>
    var clickableLinkElement = document.querySelector('[data-js-clickable-link]');
    var textLogElement = document.querySelector('[data-js-text-log]');
    var captureDocumentHandler = function(clickEvent){
        textLogElement.innerHTML += 'CAPTURE PHASE - Document was clicked<br>';
    };
    var captureBodyHandler = function(clickEvent){
        clickEvent.stopImmediatePropagation();
        textLogElement.innerHTML += 'CAPTURE PHASE - Body was clicked<br>';
        clickEvent.preventDefault();
        textLogElement.innerHTML += 'CAPTURE PHASE - "preventDefault()" was called<br>';
    };
    var captureLinkHandler = function(clickEvent){
        clickEvent.preventDefault();
        textLogElement.innerHTML += 'CAPTURE PHASE - Link was clicked<br>';
    };
    var bubblingLinkHandler = function(clickEvent){
        textLogElement.innerHTML += 'BUBBLING PHASE - Link was clicked<br>';
    };
    var bubblingBodyHandler = function(clickEvent){
        textLogElement.innerHTML += 'BUBBLING PHASE - Body was clicked<br>';
    };
    var bubblingDocumentHandler = function(clickEvent){
        textLogElement.innerHTML += 'BUBBLING PHASE - Document was clicked<br>';
    };
    document.addEventListener('click', captureDocumentHandler, true);
    document.body.addEventListener('click', captureBodyHandler, true);
    clickableLinkElement.addEventListener('click', captureLinkHandler, true);
    clickableLinkElement.addEventListener('click', bubblingLinkHandler);
    document.body.addEventListener('click', bubblingBodyHandler);
    document.addEventListener('click', bubblingDocumentHandler);
</script>

Real world use cases

There's a lot going on in the examples here. As developers we have lots of control, and often do not need to use most of the tools available to us. An example of where fine-grained event control can come in handy is in highly interactive web applications such as a painting app. Let's say the user can select from a number of colours from an easel, sitting on top of a paint canvas. The easel can be moved around by the user to their preferred location on the screen. The HTML may look like this:

<div class="paint-canvas">
    <section class="easel" data-js-easel>
        <button class="colour colour--red" data-js-colour="red"></button>
        <button class="colour colour--yellow" data-js-colour="yellow"></button>
        <button class="colour colour--blue" data-js-colour="blue"></button>
    </section>
</div>

The developer can use event propagation in both the capture and bubbling phases to trigger different functionality. Binding a "mousedown event" listener on the [data-js-colour] elements in the capture phase and stopping propagation will ensure that mousedown events are never triggered on the easel itself. A click event can also be bound to these elements to "select" a colour when clicked:

var colourMousedownListener = function(mousedownEvent){
    mousedownEvent.stopImmediatePropagation();
};
var colourClickListener = function(clickEvent){
    clickEvent.preventDefault();
    selectColour(clickEvent.target.dataset.jsColour);
};
var bindMousedownEventToColourButton = function(colourElement){
    colourElement.addEventListener('mousedown', colourMousedownListener);
};
var bindClickEventToColourButton = function(colourElement){
    colourElement.addEventListener('click', colourClickListener);
};
var colourButtons = document.querySelectorAll('[data-js-colour]');
colourButtons.forEach(bindMousedownEventToColourButton);
colourButtons.forEach(bindClickEventToColourButton);

The developer can then listen for mousedown events on the easel element to trigger "drag" functionality, allowing the user to move the easel around the screen:

var easelMouseupListener = function(mouseupEvent){
    easelIsPressed = false;
};
var easelMousedownListener = function(mousedownEvent){
    mousedownEvent.stopImmediatePropagation();
    easelIsPressed = true;
};
document.querySelector('[data-js-easel]').addEventListener('mousedown', easelMousedownListener);

Finally, a mousemove event listener can be added to the document, which will either allow the user to move the easel around the screen if the easel has been pressed, or drag the paint brush around if not:

var documentMouseMoveListener = function(mousemoveEvent){
    if(easelIsPressed){
        moveEasel(mousemoveEvent);
    } else {
        paintToScreen(mouseMoveEvent);
    }
};
document.addEventListener('mousemove', documentMouseMoveListener);

Summary

In summary, we have a lot of control over how events are listened for in web applications. We as developers are able to listen for events in both the capture and the bubbling phases, and prevent default functionality as well as prevent other bound event listeners from being triggered. This level of control allows us to create highly interactive and complex user interfaces, however also gives us the potential to confuse users by moving away from the functionality they expect web sites to give them. Hopefully this article has shed some light on the complexities around event bubbling and propagation and given you some insight in how we use these tools in the right way in our applications.

Glossary

Binding

Developers are able to bind functions to events in a web application. For example, we may bind a function named "respondToClickEvent" to a click event on a link element. "Attach" is often used interchangeably with bind - "I attached the respondToClickEvent function to the click event on the link element" / "The respondToClickEvent function is bound to click events at the document level".

Bubbling phase

The bubbling phase of an event is the phase in which the event travels up from the lowest nested element in the document to the highest, triggering event listeners on all elements up the chain. In this phase, we say the event is "bubbling up" through the document. In our examples, click events bubble up from the link element to the body element and finally to the document. The bubbling phase happens after all events in the capture phase have occurred.

Capture phase

The capture phase of an event is the phase in which the event travels down from the highest element in the document to the lowest nested element, triggering event listeners on all elements down the chain. In this phase, we say the event is "trickling down" through the document. In our examples, click events trickle down from the document, through the body element and finally to the link element in the capture phase, before triggering event listeners back up the chain in the bubbling phase.

Dispatch

When an event has occurred in a software application, it is said to have been "dispatched". For example - "The click event was dispatched, but my event listener was never triggered", or "When the submit event was dispatched on the form, the validation script was ran".

Event

An event is "something happening" in a software application. For example, a click on a button / a form being submitted / the page scrolling down or up. In a web application, events are the things that cause actions in the browser. When a user clicks on a link to another web site, the web browser responds to the "click event" by loading that web site.

Listener

A listener is a function that has been bound to an event in a software application. This function is said to "listen" for the event and respond to it accordingly. For example, when we bind a "respondToClickEvent" function to the click event on a link element, the "respondToClickEvent" function is said to be listening for the click event on that element. The function is known as an "event listener".

Propagation

Propagation is the act of an event working its way down through nested elements in the capture phase of the event lifecycle, or back up to the document level in the bubbling phase. The event is said to "propagate" to other elements. Developers have the ability to stop propagation flowing to other elements (and therefore preventing other event listeners from triggering) by using the "stopPropagation" and "stopImmediatePropagation" methods on the event.

Trigger

When the code in an event listener function is ran, the event is said to have "triggered" the event listener. The word "fire" is often used interchangeably. For example - "My respondToClickEvent listener was triggered as a result of event propagation", or "The respondToClickEvent was fired when the user tapped the button".