React popularized the idea of context-propagation within our applications with its context API. In the world of React, context is used as an alternative to prop-drilling and synchronizing state across different parts of the apps.
“Context provides a way to pass data through the component tree without having to pass props down manually at every level.”
— React Docs
You can imagine React’s context as some kind of a “wormhole” that you can pass values through somewhere up your component tree and access them further down in your children’s components.
The following snippet is a rather simplistic (and pretty useless) example of React’s context API, but it demonstrates how we can use values defined higher up in the component tree without passing them explicitly to the child components.
In the snippet below, we have our app that has a
Color component in it. That Color component displays a message containing the message defined in its parent component — the app, only without having it being passed directly as a prop to the component, but rather — having it “magically” appear through the use of
import createContext, useContext from 'react' const MyContext = createContext(); function App() return ( <MyContext.Provider value=color: "red" > <Color/ > </MyContext.Provider > ); function Color() const color = useContext(MyContext); return <span >Your color is: color</span >
While the use-case for context propagation is clear when building user-facing applications with a UI framework, the need for a similar API exists even when not using a UI framework at all or even when not building UI.
Why Should We Care About This?
In my eyes, there are two reasons to actually try and implement it.
First, as a user of a framework — it is very important to understand how it does things. We often look at the tools we use as “magic” and things that just work. Trying to build parts of them for yourself demystifies it and helps you see that there’s no magic involved and that under the hood, things can be quite simple.
Second, the context API can come in handy when working on non-UI apps as well.
Whenever we build any sort of a medium to a large application, we are faced with functions that call one another, and the call stack may go multiple layers deep. Having to pass arguments further down can create a lot of mess — especially if you don’t use all these variables at all levels. In the world of React, we call it “prop drilling.”
Alternatively, if you are a library author and you rely on callbacks passed to you by the consumer, you may have variables declared at different levels of your runtime, and you want them to be available further down. As an example, take a unit testing framework.
describe('add', () => it('Should add two numbers', () => expect(add(1, 1)).toBe(2); ); );
In the following example, we have this structure:
describegets called, and calls the callback function passed to it.
- within the callback, we have an
What Do We Want Done?
Let’s now write the basic implementation for our unit testing framework. I am taking the very naive and happy-path approach to make the code as simple as possible, but this, of course, not something you should use in real life.
function describe(description, callback) callback() function it(text, callback) try callback() console.log("✅ " + text) catch console.log("🚨 " + text)
In the example above, we have the “describe” function that calls its callback. That callback may contain different calls to “it.” “it,” in its turn, logs whether the test is successful or failing.
Let’s assume that, along with the test message, we also want to log the message from “describe”:
describe('calculator: Add', () => it("Should correctly add two numbers", () => expect(add(1, 1)).toBe(2); ); );
Would log to the console the test message prepended with the description:
"calculator: Add > ✅ Should correctly add two numbers"
To do this, we need to somehow have the description message “hop over” the user code and, somehow, find its way into the “it” function implementation.
What Solutions Might We Try?
When trying to solve this problem, there are multiple approaches we might try. I will try to go over a few, and demonstrate why they might not be suitable in our scenario.
- Using “this”
We could try to instantiate a class and have the data propagate through “this,” but there are two problems here. “this” is very finicky. It doesn’t always work as expected, especially when factoring arrow functions, which use lexical scoping to determine the current “this” value, which means our consumers will have to use the function keyword.
Along with that, there is no relationship between “test” and “describe,” so there is no real way to share the current instance.
- Emitting an event
To emit an event, we need someone to catch it. But what if we have multiple suites running at the same time? Since we have no relationship between the test calls and their respected “describes,” what would prevent the other suites from catching their events as well?
- Storing the message on a global object
Global objects suffer from the same problems as emitting an event, and also, we pollute the global scope.
Having a global object also means that our context value can be inspected and even modified from outside of our function run, which can be very risky.
- Throwing an error
This can technically work: our “describe” can catch errors thrown by “it,” but it means that on the first failure, we will halt the execution, and no further tests will be able to run.
Context To The Rescue!
By now, you must have guessed that I am advocating for a solution that would be somewhat similar in design to React’s own context API, and I think that our basic unit testing example could be a good candidate for testing it.
The Anatomy Of Context
Let’s break down what are the parts that React’s context is comprised of:
- React.createContext — creates a new context, basically defines a new specialized container for us.
- Provider — the return value createContext. This is an object with the “provider” property. The provider property is a component in itself, and when used within a React application, it is the entry to our “wormhole.”
- React.useContext — a function that, when called within a React tree that’s wrapped with a context, serves as an exit point from our wormhole, and allows to pull values out of it.
Let’s take a look at React’s own context:
It looks like the React context object is quite complex. It contains a Provider and a Consumer that are actually React Elements. Let’s keep this structure in mind going forward.
Knowing what we now know about React’s context, let’s try to think how its different parts should interact with our unit testing example. I am going to make a nonsensical scenario just so we can imagine the different components working in real life.
const TestContext = createContext() function describe(description, callback) // <TestContext.Provider value=description > callback() // </TestContext.Provider > function it(text, callback) // const description = useContext(TestContext); try callback() console.log(description + " > ✅ " + text) catch console.log(description+ " > 🚨 " + text)
But clearly, this cannot work. First, we can’t use React Elements in our vanilla JS code. Second, we cannot use React’s context outside of React. Right? Right.
So let’s adapt that structure into real JS:
const TestContext = createContext() function describe(description, callback) TestContext.Provider(description, () => callback() ); function it(text, callback) const description = useContext(TestContext); try callback() console.log(description + " > ✅ " + text) catch console.log(description+ " > 🚨 " + text)
Well, mostly — instead of our ContextProvider component, we’re using TextContext.Provider, which takes an object with the references to our values, and
useContext() that serves as our portal — so we can tap into our wormhole.
Can this work, though?
Drafting Our API
Now that we have the general concept of how we’re going to use our context, let’s start by defining the functions we’re going to expose. Since we already know how the React Context API looks like, we can base it on that.
function createContext() return Provider, Consumer function Provider(value, callback) function Consumer() function useContext(ctxRef)
We’re defining two functions, just like React.
useContext. createContext returns a Provider and a Consumer, just like React’s context, and useContext takes in a context reference.
Some Concepts To Be Aware Of Before We Dive In
These two seemingly unrelated ideas are what allow our context to work. The assumption is that, if we set some value within
Provider and call our callback, our values will remain and be available all throughout our synchronous function run. We just need a way to access it. That’s what
useContext is for.
Storing Values In Our Context
Context is used to propagate data throughout our call stack, so the first thing we want to do is actually store information on it.
Let’s define a
contextValue variable within our
createContext function. Residing within the closure of
createContext, guarantees that all functions defined within createContext will have access to it even later on.
function createContext() let contextValue = undefined; function Provider(value, callback) function Consumer() return Provider, Consumer
Now, that we have the value stored in the context, our
Provider function can store the value it accepts on it, and the
Consumer function can return it.
function createContext() let contextValue = undefined; function Provider(value, callback) contextValue = value; function Consumer() return contextValue; return Provider, Consumer
To access the data from within our function, we can simply call our Consumer function, but just so our interface works exactly like React’s, let’s also make useContext have access to the data.
function useContext(ctxRef) return ctxRef.Consumer();
Calling Our Callbacks
function createContext() let contextValue = undefined; function Provider(value, callback) contextValue = value; callback(); contextValue = undefined; function Consumer() return contextValue; return Provider, Consumer
That’s all there is to it. Really. If our function is called with the Provider function, all throughout its execution it will have access to the Provider value.
What If We Have A Nested Context?
Nesting of contexts is something that can happen. For example, when I have a
describe within a
describe. In such a case, our context will break when exiting the inner-most context, because after each callback run, we reset the context value to undefined, and since both layers of the context share the same closure — the inner-most Provider will reset the value for the layers above it.
function Provider(value, callback) contextValue = value; callback(); contextValue = undefined;
Luckily, it is very easy to handle. When entering a context, all we need to do is save its current value in a variable and set it back to it when we exit the context:
function Provider(value, callback) let currentValue = contextValue; contextValue = value; callback(); contextValue = currentValue;
Now, whenever we step out of context, it will go back to the previous value, and if there are no more layers of context above, we will go back to the initial value — which is undefined.
Another feature that we didn’t implement today is the default value for the context. In React, you can initialize the context with a default value that will be returned by the Consumer/useContext in case we are not inside a running context.
If you’ve got this far, you have all the knowledge and tools to try and implement it by yourself — I’d love to see what you come up with.
Is This Being Used Anywhere?
Yes! I actually built the context package on NPM that does exactly that, with some modifications and a bunch of more features — including full typescript support, merging of nested contexts, return values from the “Provider” function, context initial values, and even context registration middleware.
You can inspect the full source code of the package here: https://github.com/ealush/vest/blob/latest/packages/context/src/context.ts
And it is being used extensively inside Vest validation framework, a form validation framework that’s inspired by unit testing libraries such as Mocha or Jest. Context serves as Vest’s main runtime, as can be seen here.
Further Reading on Smashing Magazine