In the earlier article the unreasonable effectiveness of vanilla JS
I explained a vanilla web version of the React tutorial example Scaling up with Reducer and Context.
That example used a technique for context based on Element.closest()
.
While that way of obtaining a context is very simple, which definitely has its merits,
it also has some downsides:
- It cannot be used from inside a shadow DOM to find a context that lives outside of it without clumsy workarounds.
- It requires a custom element to be the context.
- There has to be separate mechanism to subscribe to context updates.
There is in fact, or so I learned recently, a better and more standard way to solve this known as the context protocol. It's not a browser feature, but a protocol for how to implement a context in web components.
This is how it works: the consumer starts by dispatching a context-request
event.
The event will travel up the DOM tree (bubbles = true), piercing any shadow DOM boundaries (composed = true),
until it reaches a listener that responds to it. This listener is attached to the DOM by a context provider.
The context provider uses the e.context
property to detect whether it should respond,
then calls e.callback
with the appropriate context value.
Finally it calls e.stopPropagation()
so the event will stop bubbling up the DOM tree.
This whole song and dance is guaranteed to happen synchronously, which enables this elegant pattern:
If no provider is registered the event's callback is never called and the default value will be used instead.
Instead of doing a one-off request for a context's value it's also possible to subscribe to updates by setting its subscribe property to true. Every time the context's value changes the callback will be called again. To ensure proper cleanup the subscribing element has to unsubscribe on disconnect.
It is recommended, but not required, to listen for and call unsubscribe functions in one-off requests, just in case a provider is overzealously creating subscriptions. However, this is not necessary when using only spec-compliant providers.
Providers are somewhat more involved to implement. There are several spec-compliant libraries that implement them, like @lit/context and wc-context. A very minimal implementation is this one:
This minimal provider can then be used in a custom element like this:
Which would be used on a page like this, with <my-subscriber>
requesting
the theme by dispatching a context-request
event.
Notice in the above example that the theme-toggle
context is providing a function.
This unlocks a capability for dependency injection where API's to control page behavior
are provided by a context to any subscribing custom element.
Don't let this example mislead you however. A provider doesn't actually need a dedicated custom element, and can be attached to any DOM node, even the body element itself. This means a context can be provided or consumed from anywhere on the page.
And because there can be more than one event listener on a page, there can be more than one provider providing the same context. The first one to handle the event will win.
Here's an example that illustrates a combination of a global provider attached to the body (top panel),
and a local provider using a <theme-context>
(bottom panel).
Every time the <theme-toggle>
is reparented it resubscribes to the theme from the nearest provider.
The full implementation of this protocol can be found in the tiny-context repo on Github.