When you’re composing custom web components, you need to understand how events bubble up through the DOM because that’s how children and parents communicate—props down, events up.

When an event bubbles, it becomes part of your component’s API and every consumer along the event’s path must understand the event. Whether you’re composing a simple or complex component, it’s important to understand how bubbling works, so you can choose the most restrictive bubbling configuration that works for your component.

This article covers two composition patterns: static and dynamic, which uses slots. In each pattern, a child component that sits at the bottom of the composition sends a custom event up through the component tree. I’ll look at how the event bubbles for every configuration of bubbles and composed, so you deeply understand how to configure events in your compositions.

NOTE:
This article applies to working with Lightning Web Components on Lightning Platform and Open Source. Because Salesforce supports older browser versions that don’t fully support native shadow DOM, Lightning Web Components on Lightning Platform uses a synthetic version of shadow DOM. On open source, you can choose to use synthetic or native shadow DOM. When there is a behavior difference between the two, we call it out. Due to the synthetic shadow, when you use Dev Tools to look at markup in a browser, you don’t see the #shadow-root tag.

Shadow DOM

If you’re already familiar with shadow DOM and slots, skip to the first pattern: Static Composition.

Web components create and dispatch DOM events, but there are two things about web components that make working with events a little different: shadow DOM and slots. First I’ll explain shadow DOM and then slots.

Every web component’s DOM is encapsulated in a shadow DOM that other components can’t see. When an event bubbles (bubbles = true), it doesn’t cross a shadow boundary unless you configure it to (composed = true).

Shadow DOM enables component encapsulation. It allows a component to have its own shadow tree of DOM nodes that can’t be accidentally accessed from the main document and can have local style rules.

The shadow root is the top-most node in a shadow tree. This node is attached to a regular DOM node called a host element.

The shadow boundary is the line between the shadow root and the host element. It’s where the shadow DOM ends and the regular DOM begins. DOM queries and CSS rules can’t cross the shadow boundary, which creates encapsulation. The regular DOM is also called the light DOM to distinguish it from the shadow DOM.

Whether a DOM is a light DOM or shadow DOM depends on the point of view.

  • From the point of view of a component’s JavaScript class, the elements in its template belong to the light DOM. The component owns them; they’re regular DOM elements.
  • From the point of view of the outside world, those same elements are part of the component’s shadow DOM. The outside world can’t see them or access them.

Slots

A web component can contain <slot></slot> elements. Other components can pass elements into a slot, which allows you to compose components dynamically.

When a component has a <slot></slot>, a container component can pass light DOM elements into the slot.

The browser renders the flattened tree, which is what you see on the page. The important thing to understand is that the DOM passed into the slot doesn’t become part of the child’s shadow DOM; it’s part of the container’s shadow DOM.

When we use slots, even though the content appears to be rendered inside the slot element, the actual element doesn’t get moved around. Rather, a “pointer” to the original content gets inserted into the slot. This is an important concept to understand in order to make sense of what’s happening with our events.

Events

To create events in a Lightning Web Component, use the CustomEvent interface, which inherits from Event. In Lightning Web Components, CustomEvent provides a more consistent experience across browsers, including Internet Explorer.

When you create an event, define event bubbling behavior using two properties: bubbles and composed.

bubbles
A Boolean value indicating whether the event bubbles up through the DOM or not. Defaults to false.

composed
A Boolean value indicating whether the event can pass through the shadow boundary. Defaults to false.

Important
Events become part of your component’s API, so it’s best to use the least disruptive, most restrictive configuration that works for your use case.

To get information about an event, use the Event API.

  • event.target — A reference to the element that dispatched the event. As it bubbles up the tree, the value of target changes to represent an element in the same scope as the listening element. This event retargeting preserves component encapsulation. We’ll see how this works later on.
  • event.currentTarget — A reference to the element that the event handler is attached to.
  • event.composedPath() — Interface that returns the event’s path, which is an array of the objects on which listeners will be invoked, depending on the configuration used.d.

Static Composition

A static composition doesn’t use slots. Here we have the simplest example: c-app composes component c-parent, which in turn composes c-child.

We fire an event, buttonclick, from c-child whenever a click action happens on its button element. We have attached event listeners for that custom event on the following elements:

  • body
  • c-app host
  • c-parent host
  • div.wrapper
  • c-child host

The flattened tree looks like this:

Here is a visual representation:

Now we’ll look at how the event bubbles with each event configuration.

{bubbles: false, composed: false}

With this configuration, only c-child gets to react to the buttonclick event fired from c-child. The event doesn’t bubble past the host. This is the recommended configuration because it provides the best encapsulation for your component. This is where you start, then from here you can start incorporating other more permissive configurations, as the ones we’re about to explore in the next few sections, to fit your requirements.

If we inspect c-child handler’s we find these values on the event object:

  • event.currentTarget = c-child
  • event.target = c-child

{ bubbles: true, composed: false }

With this configuration, the buttonclick event from c-child event travels from bottom to top until it finds a shadow root or the event gets canceled. The result, in addition to c-child, div.wrapper can also react to the event.

Use this configuration to bubble up an event inside the component’s template, creating an internal event. You can also use this configuration to handle an event in a component’s grandparent.


And again, here are what the events are telling us for each handler:

c-child handler:

  • event.currentTarget = c-child
  • event.target = c-child

div.childWrapper handler:

  • event.currentTarget = div.childWrapper
  • event.target = c-child

{ bubbles : false, composed : true }

This configuration is supported for native shadow DOM, which means it isn’t supported on Lightning Platform. Even for LWC open source, this configuration isn’t suggested, but it’s helpful for understanding how events bubble in a shadow DOM context.

Composed events can break shadow boundaries and bounce from host to host along their path. They don’t continue to bubble beyond that unless they also set bubbles:true.

In this case, c-child, c-parent, and c-app can react to the event. It’s interesting to note that div.wrapper can’t handle the event, because the event doesn’t bubble in the shadow itself.

Let’s see what the handler’s have to say about the event:

c-child handler:

  • event.currentTarget = c-child
  • event.target = c-child

c-parent handler:

  • event.currentTarget = c-parent
  • event.target = c-parent

c-app handler:

  • event.currentTarget = c-app
  • event.target = c-app

It’s interesting to note that div.wrapper can’t handle the event because even if the event propagates from shadow to shadow, it doesn’t bubble in the shadow itself.

Let’s pause here and notice that even though the event was fired from c-child, when it gets to c-parent and c-app , it shows the host as both target and currentTarget.

What’s happening? Event retargeting at its finest. As the buttonclick event leaves c-child‘s shadow, the event gets treated as an implementation detail and its target is changed to match the scope of the listener.

This is one of the reasons why composed:true flags should be used with caution, since the semantics of c-child‘s event and its receiver don’t match. c-child fired the event, but to c-app, it looks like c-app fired it.

Using the { bubbles:false, composed:true } configuration is an anti-pattern. The correct pattern is for receivers that are able to understand buttonclick to repack and send the event with the proper semantics. For example, c-parent could receive the event from c-child and expose a new custom event, so that elements on c-app‘s light tree could understand.

{ bubbles : true, composed : true }

This configuration isn’t suggested because it creates an event that crosses every boundary. Every element gets the event, even the regular DOM elements that aren’t part of any shadow. The event can bubble all the way up to the body element.

When firing events this way, you can pollute the event space, leak information, and create confusing semantics. Events are considered part of your component’s API, so make sure that anyone on the event path is able to understand and handle the event’s payload if it has one.

Finally, let’s explore the event’s values:

c-child handler:

  • event.currentTarget = c-child
  • event.target = c-child

div.wrapper handler:

  • event.currentTarget = div.wraper
  • event.target = c-child

c-parent handler:

  • event.currentTarget = c-parent
  • event.target = c-parent

c-app handler:

  • event.currentTarget = c-app
  • event.target = c-app

body handler:

  • event.currentTarget = body
  • event.target = c-app

Dynamic Composition with Slots

Now we’ll explore how events bubble in compositions that use slots. We have a c-parent element that accepts any content via the special <slot> element. Using c-app, we compose c-parent and we pass c-child as its child.

The code fires an event from c-child named buttonclick :

The code attaches event listeners on the following elements:

  • c-child host
  • slot
  • c-parent host
  • div.wrapper
  • c-app host
  • body

This is the flattened tree as it looks in the browser’s Developer Tools.

Remember that when we use slots, even though the content appears to be rendered inside the slot element, the actual element doesn’t get moved around. Rather, a “pointer” to the original content gets inserted into the slot. This is an important concept to understand in order to make sense of what’s happening with our events.

This view of the flattened tree shows you where the content passed into the slot really sits in the DOM. c-child is part of c-app`’s shadow DOM. It isn’t part of c-parent’s shadow DOM.

With that in mind, let’s look at the results we get with all the different configurations.

{bubbles : false, composed : false}

As with the previous composition example, the event doesn’t move past c-child, which is where it was fired. This is also the recommended configuration for dynamic compositions because it provides the best encapsulation for your component.

c-child handler:

  • event.currentTarget = c-child
  • event.target = c-child

{bubbles : true, composed : false}

NOTE: This configuration behaves differently when you’re using native shadow DOM with Lightning Web Components: Open Source. With native shadow DOM, the event doesn’t pass out of the slot unless composed is also true.

With this configuration, the connectedchild event fired from c-child event travels from bottom to top until it finds a shadow root or the event gets canceled.

In our static example, we mention how an event with this configuration bubbles until it travels from bottom to top until it finds a shadow root or the event gets canceled. Let’s explore what event.composedPath() has to say about this use case:

As you see, it looks as if the event would be able to break out of my-parent‘s #shadow-root and bubble up outside, even though, composed is set to false. Confused? You should be. Let’s look closer.

There is a “double-bubble” effect here. Because slotted content doesn’t really move things around but rather creates pointers from the light DOM and into a particular slot, when we fire the buttonclick event, the event gets to travel from both places, which creates a composedPath like the one we just saw.

Finally here is what our event handlers tell us about targets:

c-child handler:

  • event.currentTarget = c-child
  • event.target = c-child

c-parent handler:

  • event.currentTarget = c-parent
  • event.target = c-child

div.wrapper handler:

  • event.currentTarget = div
  • event.target = c-child

Notice how div.wrapper‘s target is still c-child, since the event technically never crossed c-parent‘s shadow root.

{bubbles : false, composed : true}

Like simple composition, this configuration is an anti-pattern and it isn’t supported on Lightning Platform, but it’s helpful for understanding how events bubble in a shadow DOM context.

The event bounces from one host to another host as long as they’re in different shadows. In this case, c-child isn’t part of c-parent‘s shadow, but instead part of c-app’s shadow, so it jumps directly to c-app.

The event.composedPath() results also throws some interesting results:

0: my-child
1: slot
2: document-fragment
3: my-parent
4: div.wrapper
5: document-fragment
6: my-test
7: body
8: html
9: document
10: Window

It’s showing that when we set composed:true, the event can break out of every shadow root. In this case, it doesn’t, since the bubbles property is set to true. Instead, it jumps from host to host.

Targets:

c-child handler:

  • event.currentTarget = c-child
  • event.target = c-child

c-app handler:

  • event.currentTarget = c-app
  • event.target = c-child

Again, it’s helpful to look at this view of the flattened tree to remember that c-child in the slot is just a pointer.

{bubbles : true, composed : true}

This is the “brute force” scenario, and like our static composition example, the event bubbles everywhere, which is an anti-pattern. Every element on the event’s composed path can handle the event.

c-child handler:

  • event.currentTarget = c-child
  • event.target = c-child

c-parent handler:

  • event.currentTarget = c-parent
  • event.target = c-child

div.wrapper handler:

  • event.currentTarget = div
  • event.target = c-child

c-app handler:

  • event.currentTarget = c-app
  • event.target = c-app

body handler:

  • event.currentTarget = body
  • event.target = c-app

Conclusion

Events are part of your API. Be mindful of the API’s consumers. Every consumer along the event’s path must understand the event and how to handle its payload.

Be mindful when setting the composed and bubbles flags, since they can have undesired results. Use the least disruptive settings that work for your use case, with the least disruptive being (bubbles: false, composed: false).

And finally, remember that dynamic compositions using slots introduce an extra level of complexity since you have to deal with assigned nodes inside your slot elements.

About the author

Gonzalo Cordero works as a software engineer at Salesforce.
Github: @gonzalocordero

Get the latest Salesforce Developer blog posts and podcast episodes via Slack or RSS.

Add to Slack Subscribe to RSS