Context Propagation Beyond React
React's Context API brought context propagation into the mainstream of frontend development. It provides a mechanism to pass data through a component tree without threading props through every level manually. The concept, however, isn't confined to UI frameworks. Any JavaScript application that involves deep call stacks can benefit from a similar pattern.
Consider a testing framework. A describe block wraps several it calls in callbacks. When an it callback logs a result, it might need data from the describe scope — without that data being passed explicitly through each function parameter. This is a classic case of needing context to "hop over" user-defined code layers.
Why Build Your Own Context?
There are practical reasons to understand how context works under the hood. First, demystifying the tools we use on a daily basis. Seeing that features like React's Context are built on simple, accessible JavaScript concepts removes a layer of "magic" and makes debugging and design decisions easier.
Second, the pattern applies to non-UI code. Middle-to-large applications often have functions nested several layers deep. Passing arguments through every layer creates clutter, especially when intermediate functions don't use them. This is prop drilling by another name. A context mechanism allows values declared at a higher runtime level to be available deeper in the call stack.
A Simple Testing Framework Example
Imagine a naive implementation of a testing framework:
function describe(description, callback) {
callback()
}
function it(text, callback) {
try {
callback()
console.log("✅ " + text)
} catch {
console.log("🚨 " + text)
}
}
This code works, but if we need the description from describe to be logged alongside the result inside it, we need a way to pass data across the user's callback.
"calculator: Add > ✅ Should correctly add two numbers"
Obvious Solutions and Their Flaws
Potential solutions exist, but none are ideal for this scenario:
- Using
thisfor propagation: This is unreliable. Arrow functions use lexical scoping forthis, forcing consumers to use thefunctionkeyword. More critically, there's no inherent relationship between the instance indescribeand the instance init, so sharing a state object is problematic. - Emitting events: Events require a catcher. If multiple test suites run simultaneously, there's no relationship between an
itcall and its wrappingdescribe. Nothing prevents one suite's event from being caught by another. - Storing messages globally: This pollutes the global scope and shares the same problems as events. Global data can be modified from anywhere, posing a risk.
- Throwing an error: Technically, the outer
describecould catch an error thrown by a nested context. But execution would halt at the first failure, preventing subsequent tests from running.
The Anatomy of a Context API
React's Context API has three core parts:
React.createContextcreates a new specialized container.- The
Provider, a property on the returned object, is the entry point. React.useContextserves as the exit point, pulling values out of the context within the wrapped tree.
We can structure a similar JavaScript API for our testing framework:
function createContext() {
return {
Provider,
Consumer
}
function Provider(value, callback) {}
function Consumer() {}
}
function useContext(ctxRef) {}
We define two functions: createContext and useContext. The former returns a Provider and a Consumer. The Provider sets the value; useContext or the Consumer reads it. We'll use a variable defined within the closure of createContext to store the shared value, making it accessible to all functions defined within that scope.
function createContext() {
let contextValue = undefined;
function Provider(value, callback) {
contextValue = value;
}
function Consumer() {
return contextValue;
}
return {
Provider,
Consumer
}
}
This works because of two core JavaScript concepts: closures and synchronous execution. A closure lets inner functions access the outer function's scope, even after the outer function has finished. JavaScript's synchronous nature means that once the Provider sets a value and calls its callback, no other code will run until the callback stack is complete. Therefore, we only need to clean up the context value right after the callback finishes.
function createContext() {
let contextValue = undefined;
function Provider(value, callback) {
contextValue = value;
callback();
contextValue = undefined;
}
function Consumer() {
return contextValue;
}
return {
Provider,
Consumer
}
}
That's the core mechanism. Any function called within the Provider's execution has access to its value.
Handling Nested Contexts
The basic implementation breaks with nested contexts, like a describe within another describe. Both layers share the same closure, so the inner Provider would reset the context value to undefined when it exits, wiping out the outer layer's value.
The fix is straightforward. When entering a nested context, save the current value before setting the new one, then restore it upon exit.
function Provider(value, callback) {
let currentValue = contextValue;
contextValue = value;
callback();
contextValue = currentValue;
}
This ensures the context returns to its previous value at each nesting level, falling back to the initial undefined when the outermost context exits. In this way, a minimal, functional context propagation API demonstrates that the mechanism is built on simple, foundational language features.



