Web Components and You (part 10): Provider Patterns
Published 2024-08-31, About 6 minute read.
You won't go very far building web components before you realize that sometimes components need to be aware of each other. This could be to pass and react to state changes, for contextual information, or to send actions or execute methods.
There's a few strategies to do this:
- Using good ol' fashioned DOM methods to find parents
- Utilizing events to set up a provider pattern
- Context protocol for web components
- Using
context
(in this case, lit context!)
Using parents and ancestors directly to communicate
Let's say that you have an input that acts as a text filter for a list. This
list is a component in the ancestor list of your button. So we have this
relationship: my-list
> my-input
In framework land, we pass functions to children as props, and this is all fine and dandy in framework land. In the DOM, though, we can't do this declaratively. We can only pass attributes or string values in attributes. So how are we to pass functionality?
One option is for the filter list to reach out to its parent explicitly, and
run a filterByText
method that the parent makes available
publicly. The button can use normal DOM traversal, in this case
this.parent
and just call the method by that reference.
Here's the contrived example in action:
In this case, we didn't provide a method. We provided a public property that the list would react to. Could we have called a method? Certainly. Check this out:
Isn't this nice? These components are nicely composed and work with each other
by just reaching out to each other through parentElement
. And
yes, they are tightly coupled, but that is expected in this API. We could
always spend some time using something like
ow to validate components are
allowed in that control slot.
But there's an issue... what if there are some layers between the the controls
and the parent provider? We can't just use parentElement
Oh noes! how can it break that easily? The answer is that
parentElement
is probably not the best way to find the parent
provider. There maby be a million divs between these consumers and the
provider. So what about instead we use closest(parent-selector)
?
Yay! It works again! We've covered the edge cases, right?
Nope, sorry. Take a look here where the consumers (the button and input) are in an additional layer of shadow DOMs.
We have another situation where this ain't going to cut it. If we are nested
in multiple levels of shadow DOMs, closest
is not going to
correctly find our parent provider. The reason is that you need special logic
to "rise out of" shadow DOMs and keep searching up the DOM tree.
There is a function you can use instead of closest to recursively search back up the tree:
So FINALLY we have something that is somewhat robust:
Alright, so that's how we can possibly get a provider. Using DOM methods. It's worth looking next at using events instead. It may be a lot nicer!
Using events to set up a provider pattern
Events can bubble up and pass through shadow DOMs. Why don't we use those instead?
Alright, so the idea here is that when a consumer renders, it will emit an event that the provider will be listening for. When the provider hears this event, they provide a reference of themselves to the callback that the consumer sent along with the event. So the provider "provides" itself to the consumer as a reference. Voila! No DOM searching.
You might say, why go through all the trouble of storing a reference to
my-list
? Why not have the consumers just emit different events
for each action they need to do?
This is probably a matter of preference. I like how the consumers are provided
a reference to my-list
and can do any of its public api because
they have a stored reference.
Also, this is a leading example that leads us to...
Context protocol for Web Components
This pattern that we just saw is basically how the W3C's Web Components Community Group designed the web components context prototcol. Context is common thing in other frameworks, so this protocol provides a standard for web components to basically have the same thing, but with vanilla javascript!
Here's how it works:
-
A provider is listening for a 'context-request' event. This event should
provide a callback in which the provider should call with the provider's
context as the argument:
-
The consumer dispatches an event when it connects to the DOM. In that event,
a callback function is passed that stores the context on the consumer's
instance.
And then the consumer has a reference to the context! There are some other details, like if the consumer can subscribe and react to changes, and how to actually type these kinds of things. But basically, it's doing what we did in the last section. The consumer requests something in an event, and the provider provides it!
Using Lit's Context
Lucky us, Lit provides context to us. In vanilla javascript, you have to use what's called a context consumer reactive controller. Here's an example of a provider and consumer using Lit libraries:
Admittedly, this is a bit weird. We're using context to basically provide consumers with methods to change the list, when normally context in, for example the React world, is sharing out pure data. You can store the list data in the context, but that felt a little strange to me, and there is one hiccup: consumers aren't able to update the value of the context directly through the context.
So really this context is an API to work with my-list
, which does feel alright, at least to me!
So there it is!
It was a long meandering path, but we looked at many ways to provide a context/api to children consumers. We did it through dom traversal, solved an issue traversing through shadow DOMs, used custom events, and then finally used Lit's Context.
So what do you think? Add a comment below or find me on Bluesky or Mastadon. I also have an RSS feed here