Understanding Hooks Part 6 — useContext

Fang Jin
4 min readMay 13, 2021

What’s behind the legendary Hooks? From time to time, I wonder.

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 to dispatchEvent.

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 if Child and Parent each call setState() when handling a click event, you don't want to re-render the Child 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

The code snippets used are heavily borrowed and simplified from the early draft of Repo Haunted for lit-element.

--

--

Fang Jin
Fang Jin

Written by Fang Jin

Front-end Engineer, book author of “Designing React Hooks the Right Way” and "Think in Recursion"

No responses yet