Introduction to Event Communication
Howdy everybody, welcome to Lit University Advanced. I'm Elliott and today's lesson: event communication.
Web components are a fantastic way to share logic and rendering with an HTML element that works across all sorts of rendering environments. But to work across all framework renderers out there, it's generally a good idea to make your Lit element behave like an island and try not to infer DOM or information outside of its scope.
But if you're building a Lit element like an island, how do you communicate with it? How do you get information from one element to another? Well, like you would with almost any other element: with events.
Today we're going to talk about the whys, hows, advantages, and disadvantages of communicating with events. Let's get started.
Advantages of Events Over Callbacks
A question you may be asking is why use events when it's common in other frameworks to pass functions or callbacks around? Well, despite there being nothing actually stopping you from passing callbacks to web components, events are quite a popular communication mechanism because they are a native browser message passing feature that is tightly coupled with the DOM.
Almost every front-end framework out there has support for native DOM events, and a key feature of web components is that they're meant to fit everywhere, from a Lit web app to a mobile native Ionic Capacitor app. Therefore it makes sense to use a feature that can also fit in all these types of places.
Additionally, event listeners are much more decoupled than passing callbacks and give much more flexibility by dealing with the tree model instead. For example, if you have a list of components and want to know when a single component triggers an action, you have to pass your callback to each individual list item. Using events, you can decouple this relationship with each list item since events bubble up a tree, calling event listeners on each ancestor. This means that we only have to add one event listener on the parent list element, which is much more performant and ergonomic.
Finally, another reason events are a popular way to communicate in Lit is that Lit makes handling events clean and declarative with the @ syntax. Let's run through an example.
Example: Child-to-Parent Data Flow
In this example, we have two components: a trombone and a trombone-slide. In the render function of trombone, we render the slide and the current note. Now if we drag the slide, you'll notice that the note and octave values from the parent trombone do not update. We need to get the updated values of note and octave from the slide, and we can use events to figure out when to fetch those updated values from the slide.
If you look inside trombone-slide, you can see that we update the note and octave in the onInput method. This is a good place to notify the parent trombone component that these values have changed. Let's fire an event: this.dispatchEvent(new Event('note-changed')).
Now, let's use Lit's @ syntax to listen for this event and update the local note and octave values on the slide. Let's type @note-changed=${this.onNoteChanged}. And then let's define the onNoteChanged method.
onNoteChanged(event)... const target = event.target as TromboneSlide. And then we'll do the same thing for note, and this.octave = (event.target as TromboneSlide).octave for the octave. In this case, event.target will return the trombone-slide element. And now let's give it a go. Tada!
Now we have our slider passing its note and octave values to the parent.
Communicating between child and parent is simple enough, but how would a component communicate with its sibling using events? For this, we use a pattern called the mediator method. The mediator method is quite straightforward: you have data in child one and want to pass it to child two, without child one having to infer any information outside of its subtree.
Basically, instead of each child having to figure out how to talk to each other and potentially fight it out, the parent mediates between them. How does a parent do this? Four simple words: events up, props down.
In this case, child one should fire an event. The parent should handle the event, process whatever data it needs, and pass down the data to child two with properties.
Now let's run through a quick example. Back to our trombone example, but now let's make the trombone actually play sounds. If we drag the slide, you'll notice that there is no sound being played. Also notice that we've added a note-player element. note-player takes a note and an octave and can play or stop sound using the playing property.
The slide fires a note-changed event, and in our trombone parent component, we set the note and the octave. The mediator method suggests that we should pass the note and octave properties down to the note-player. Let's do that now. .note=${this.note}, .octave=${this.octave}.
Now that note and octave are being set, if we drag the slide, we can see that the note is being set, but there's still no sound. We need to tell note-player to start and stop playing when the user interacts with the slide.
If we look at the trombone-slide file, we have methods that are called when the pointer is down and the pointer is up. These are perfect spots to fire events. On the pointerdown method, we will fire this.dispatchEvent(new Event('interaction-start')). And on the pointerup method, we will fire this.dispatchEvent(new Event('interaction-end')).
And now let's handle them in the parent. @interaction-start... so this is where the parent should handle the communication between the two sibling components. So @interaction-start=${() => this.playing = true} and @interaction-end=${() => this.playing = false}. And let's mark playing as a reactive property: @property({type: Boolean}) playing = false; And update the note-player in the render: .playing=${this.playing}.
And now let's give it a go.
Fantastic.
Limitations and When to Use State Management
Communicating with events is great again because they are a message passing solution that is compatible with everything that supports the DOM. They provide flexibility with the DOM tree and are declarative in Lit.
But the mediator method is not a 100% perfect solution. A drawback that can come up in really large or complex applications is something that is often called prop drilling. Prop drilling is when you have to pass data from an ancestor node all the way down to a descendant many, many layers deep. This triggers a lot of unnecessary render functions.
Again, Lit is very fast and prop drilling will likely not be an issue you'll ever run into. Prop drilling only becomes a problem in really large or complex applications, and at these points you may want to migrate to a state manager or to using a context pattern. We won't get into that in this video, but I'll leave some nifty homework and reading examples down in the description.
So what does that mean for web components and events? Well, it means that event communication is useful for when you're authoring shareable standalone components, or even small to medium sized applications. It's possible to build complex or large applications using only the mediator method, but state managers may be a better fit.
Conclusion and Further Resources
That concludes this week's lesson on event communication. I've left some additional reading in the description regarding the web component community group's work on the context API, some cool visualizations on shadow DOM events, and links to state management solutions with Lit.
If you have any questions, tweet us at the hashtag #asklitdev. Also join our Lit and Friends Slack where there are thousands of fantastic people chatting Lit and web components. Class dismissed.