In Part 5, a hook useAsync
is crafted to demo how to build a custom hook from useState
and useEffect
. And in this Part 6, we’d like to continue exploring the Hook
family, maybe a more complex one this time, useContext
.
const Ancestor = () => {
const value = "Hello"
return (
<Context.Provider value={value}>
<Grandpa>
<Parent>
...
<Component />
</Parent>
...
<Grandpa>
</Context.Provider>
)
}const Component = () => {
const value = useContext(Context)
...
}
You have an Ancestor
, and a value to pass to a Component
. If they are in direct parent child relationship, we normally use props
. In this case, they are multiple levels apart, and we don’t know how far apart they are.
State
Look at the implementation of useContext
below, similar to useState
, a value
needs to be provided back. And when it’s set, it requests the element update.
const useContext = hook(class extends Hook {
update(Context) {
if (this.Context !== Context) {
this._subscribe(Context);
this.Context = Context;
}
return this.value;
} _updater(value) {
this.value = value;
this.el.update();
}
Event
The major complexity here is that the value
needs to be provided by the ancestor. Under the first update, _subscribe
is called to establish this communication.
const useContext = hook(class extends Hook {
_subscribe(Context) {
const detail = { Context, callback: this._updater }; this.el.host.dispatchEvent(new CustomEvent(contextEvent, {
detail, // carrier
bubbles: true, // to bubble up in tree
cancelable: true, // to be able to cancel
composed: true, // to pass ShadowDOM boundaries
})); const { unsubscribe, value } = detail; this.value = unsubscribe ? value : Context.defaultValue; this._unsubscribe = unsubscribe;
}
A custom data detail
, composed of the identity Context
and callback _updater
, is sent as a custom event contextEvent
and bubbles up via dispatchEvent
. The value
gets replaced back to detail
Once the events gets handled by the Context.Provider
in Ancestor
.
Communication via
dispatchEvent
is performed in a synchronous way. According to MDN, all applicable event handlers will execute and return before the code continues on after the call todispatchEvent
.
Context
function createContext(defaultValue) {
const Context = {
Provider: class extends HTMLElement {
constructor() {
this.listeners = new Set()
this.addEventListener(contextEvent, this)
}
handleEvent(event) {
const { detail } = event;
if (detail.Context === Context) {
detail.value = this.value;
detail.unsubscribe = this.unsubscribe.bind(this, detail.callback);
this.listeners.add(detail.callback);
event.stopPropagation();
}
}
unsubscribe(callback) {
if(this.listeners.has(callback)) {
this.listeners.delete(callback);
}
}
set value(value) {
this._value = value;
for(let callback of this.listeners) {
callback(value);
}
}
get value() {
return this._value;
}
},
Consumer: component(function ({ render }) {
const context = useContext(Context)
return render(context);
}),
defaultValue
}
return Context
}
So what is a Context
? From createContext
function above, it provides Provider
, Consumer
as well as a defaultValue
. Once initialized, it serves as a unique signature so that the ancestor knows which Context
a Component
subscribes to.
When the consumer Component
requests the value
from the ancestor, it also register its _update
function to the listeners
set. When the ancestor changes the value
, it goes through the listeners
and invokes them all. Imagine all connected children gets updated in next cycle.
From this design, if you have long chain of lineage, and casual usage of Consumer
, especially near the tip of the lineage tree, this approach allows targeted update without the entire tree update. On the other hand, when the lineage is short, or the usage happens everywhere, in theory, it could become less effective if not problematic.
“The key to understanding this is that no matter how many
setState()
calls in how many components you do inside a React event handler, they will produce only a single re-render at the end of the event. This is crucial for good performance in large applications because ifChild
andParent
each callsetState()
when handling a click event, you don't want to re-render theChild
twice. Dan commented on this ticket, and demoed here.
Usage
Simple as the interface is, since the value
can be anything, there’re quite a bit different usage depending on the nature of the value
.
const Root = () => (
<Context.Provider value={value} />
...
</Context.Provider>
)
If you don’t expect the value
to change, we can wire it with a constant. Unless the ancestor gets updated, the provider can’t be re-rendered.
<Context.Provider value={constValue} />
In order to change, a combo of [state, setState]
can be passed in via useState
hook. Calling setState
by any children can reset this global value. This is exactly how theming components are built.
<Context.Provider value={useState(0)} />
IMHO, allow a global value to change at any time is a bit dangerous. So if you can’t afford the children update, you can pass in an object via useRef
hook. This way you can still change the underlying value.current
without effecting the render.
<Context.Provider value={useRef(0)} />
TL;DR
A context can be created in an ancestor to hold a setting (or functionality) to be consumed via the hook useContext
in any children Component
.
Index
- Part 1, Element
- Part 2, useEffect
- Part 3, useState
- Part 4, Hook
- Part 5, Custom Hook
- Part 6, useContext <- YOU ARE HERE
The code snippets used are heavily borrowed and simplified from the early draft of Repo Haunted
for lit-element
.