This is a follow up to my second post in this series:
https://dev.to/bytebodger/throw-out-your-react-state-management-tools-4cj0
In that post, I began digging into the Context API in earnest for the first time in my experience as a React dev. Since that post a few weeks ago, I'm glad to report that I've had a chance to dive into this in some detail and I've refined the ideas in the first post.
Although I've been employed professionally as a programmer for 20+ years, I still write the majority of my code for free. In other words, I write thousands of LoC purely for myself. I bring this up because I have a personal project that's currently sitting somewhere north of 30k LoC. So I took my Context API findings and started applying them to this fairly robust codebase.
This has allowed me to assess the Context API in an environment that is much closer to "real-world apps" (and the stuff I'm building on the side definitely applies as real world apps). I've honed the techniques in the original approach - and I can highlight a few "gotchas".
Prelude
This post works from a few basic assumptions:
Most professional devs consider "prop drilling" to be an unmanageable solution for large-scale applications.
Most professional devs have come to see bolted-on state-management tools as a default must have.
The Context API is an interesting "dark horse" in the state-management arena because it's not an additional library. It's core React. And the more I've investigated it, the more I'm convinced that it's incredibly flexible, robust, and performant.
The Setup
I'm going to show a fairly-basic multi-layer app (but still more complex than most of the quick examples we see in many dev blogs). There will be no prop drilling. There will be no outside tools/packages/libraries used. I believe that what I'm about to illustrate is performant, fault-tolerant, and fairly easy to implement with no need for additional tools/packages/libraries.
I'm not going to outline App.js
. In my typical paradigm, there's no real logic that ever goes in that file, and it's only real purpose is to launch us into the application. So please, just assume that there's an App.js
file at the top of this hierarchy.
The rest of the files will be shown as a "tree" or "layered cake" structure that I typically use in my apps. This proposed "framework" does not require this structure at all. It's just the way that I tend to structure my own apps and it works well to demonstrate shared-state amongst multiple layers of a codebase.
contants.js
import React from 'react';
import Utilities from 'components/utilities';
export const ConstantsContext = React.createContext({});
export default class Constants extends React.Component {
constructor(props) {
super(props);
this.state = {
apiUrl : 'http://127.0.0.1/',
color : {
blue : '#0000ff',
green : '#00ff00',
lightGrey : '#dddddd',
red : '#ff0000',
},
siteName : 'DEV Context API Demo',
};
}
render = () => {
const {state} = this;
return (
<ConstantsContext.Provider value={state}>
<Utilities/>
</ConstantsContext.Provider>
);
};
}
Notes:
Before the component is even defined, we're exporting a constant that will ultimately house that component's context.
"Context" can, technically, hold almost anything that we want it to hold. We can shove scalar values, or objects, or functions into the context. Most importantly, we can transfer state into context. So, in this case, we put the whole of the component's state right into the context provider. This is important because, if we pass state into a prop, that means that the dependent component will update (re-render) if the underlying state is updated.
Once we've done this, those same state values will be available anywhere in the descendant levels of the app if we choose to make them available. So by wrapping this high level of the tree in
<Constants.Provider>
, we're essentially making these values available to the entire application. That's why I'm illustrating the highest level in this hierarchy as a basic place in which we can store "global" constants. This subverts a common pattern of using animport
to make globals available to all downstream components.
utilities.js
import React from 'react';
import DataLayer from 'components/data.layer';
import {ConstantsContext} from 'components/constants';
export const UtilitiesContext = React.createContext({});
let constant;
export default class Utilities extends React.Component {
constructor(props) {
super(props);
this.sharedMethods = {
callApi : this.callApi,
translate : this.translate,
};
}
callApi = (url = '') => {
// do the API call
const theUrlForTheApiToCall = constant.apiUrl;
this.helperFunctionToCallApi();
return theApiResult;
};
helperFunctionToCallApi = () => {
// do the helper logic
return someHelperValue;
};
translate = (valueToTranslate = '') => {
// do the translation logic
return theTranslatedValue;
};
render = () => {
constant = ConstantsContext.Consumer['_currentValue'];
const {state} = this;
return (
<UtilitiesContext.Provider value={this.sharedMethods}>
<DataLayer/>
</UtilitiesContext.Provider>
);
};
}
Notes:
I've set up a bucket object in the
this
scope calledthis.sharedMethods
that will hold references to any functions that I want to share down the hierarchy. This value is then passed into thevalue
for<Utilities.Provider>
. This means that these functions will be available anywhere in the descendant components where we chose to make them available.If you read the first post in this series (https://dev.to/bytebodger/throw-out-your-react-state-management-tools-4cj0), you might remember that I was dumping all of the function references into state. For a lot of dev/React "purists", this can feel a little wonky. So in this example, I created a separate bucket just to house the shared function references.
Obviously, I don't have to dump all of the component's functions into
this.sharedMethods
. I only put references there for functions that should specifically be called by descendant components. That's whythis.sharedMethods
has no reference tohelperFunctionToCallApi()
- because that function should only be called from within the<Utilities>
component. There's no reason to grant direct access for that function to downstream components. Another way to think about it is: By excludinghelperFunctionToCallApi()
from thethis.sharedMethods
object, I've essentially preserved that function as beingprivate
.Notice that the
value
for<UtilitiesContext.Provider>
does not make any mention ofstate
. This is because the<Utilities>
component has no state that we want to share to ancestor components. (In fact, in this example,<Utilities>
has nostate
whatsoever. So there's no point in including it in thevalue
for<UtilitiesContext.Provider>
.)Above the component definition, I've defined a simple
let
variable asconstant
. Inside therender()
function, I'm also setting that variable to the context that was created for the<Constants>
component. You aren't required to define it in this way. But by doing it this way, I don't constantly have to refer to the<Constants>
context asthis.constant
. By doing it this way, I can refer, anywhere in the component, toconstant.someConstantValue
andconstant
will be "global" to the entire component.This is illustrated inside the
callApi()
function. Notice that inside that function, I have this line:const theUrlForTheApiToCall = constant.apiUrl;
. What's happening here is that 1:constant
was populated with the "constant" values during the render, 2: then the value ofconstant.apiUrl
will resolve to'http://127.0.0.1/
when thecallApi()
function is called.It's important to note that
constant = ConstantsContext.Consumer['_currentValue']
is defined in therender()
function. If we want this context to be sensitive to futurestate
changes, we must define the reference in therender()
function. If, instead, we definedconstant = ConstantsContext.Consumer['_currentValue']
in, say, the constructor, it would not update with futurestate
changes.This is not a "feature" of this framework, but by structuring the app this way,
<Constants>
becomes a global store of scalar variables, and<Utilities>
becomes a global store of shared functions.
data.layer.js
import HomeModule from 'components/home.module';
import React from 'react';
import UserModule from 'components/user.module';
import {ConstantsContext} from 'components/constants';
import {UtilitiesContext} from 'components/utilities';
export const DataLayerContext = React.createContext({});
let constant, utility;
export default class DataLayer extends React.Component {
constructor(props) {
super(props);
this.state = {
isLoggedIn : false,
};
this.sharedMethods = {
logIn : this.logIn,
};
}
getModule = () => {
const {state} = this;
if (state.isLoggedIn)
return <UserModule/>;
return <HomeModule/>;
};
logIn = () => {
// do the logIn logic
};
render = () => {
constant = ConstantsContext.Consumer['_currentValue'];
utility = UtilitiesContext.Consumer['_currentValue'];
const {state} = this;
return (
<DataLayerContext.Provider value={{...this.sharedMethods, ...this.state}}>
<div style={backgroundColor : constant.color.lightGrey}>
{utility.translate('This is the Context API demo')}
</div>
{this.getModule()}
</DataLayerContext .Provider>
);
};
}
Notes:
The
backgroundColor
is picked up from the<Constants>
context.The text is translated using the
translate()
function from the<Utilities>
context.In this example,
this.sharedMethods
andthis.state
are spread into the value of<DataLayerContext.Provider>
Obviously, we're doing this because this components has bothstate
variables andfunctions
that we want to share downstream.
home.module.js
import HomeModule from 'components/home.module';
import React from 'react';
import UserModule from 'components/user.module';
import {ConstantsContext} from 'components/constants';
import {UtilitiesContext} from 'components/utilities';
let constant, dataLayer, utility;
export default class HomeModule extends React.Component {
render = () => {
constant = ConstantsContext.Consumer['_currentValue'];
dataLayer = DataLayerContext.Consumer['_currentValue'];
utility = UtilitiesContext.Consumer['_currentValue'];
return (
<div style={backgroundColor : constant.color.red}>
{utility.translate('You are not logged in.')}<br/>
<button onClick={dataLayer.logIn}>
{utility.translate('Click to Log In')}
</button>
</div>
);
};
}
Notes:
The
backgroundColor
is picked up from the<Constants>
context.The
translate()
functions are picked up from the<Utilities>
context.The
onClick
function will triggerlogIn()
from the<DataLayer>
context.There is no reason to wrap this component's
render()
function in its own context provider, because there are no more children that will need<HomeModule>
's values.
Visiblity/Traceability
From the examples above, there's one key feature that I'd like to highlight. Look at home.module.js
. Specifically, look inside the render()
function at values like constant.color.red
, dataLayer.login
, or utility.translate()
.
One of the central headaches of any global state-management solution is properly reading, tracing, and understanding where any particular variable "comes from". But in this "framework", I hope it's fairly obvious to you, even if you're just reading a single line of code, where something like constant.color.red
comes from. (Hint: It comes from the <Constants>
component.) dataLayer.logIn
refers to a function that lives in... the <DataLayer>
component. utility.translate
invokes a function that lives in... the <Utilities>
component. Even a first-year-dev should be able to just read the code and figure that out. It should be dead-simple-obvious as you browse the code.
Sure... you could set Constants.Consumer['_currentValue']
into some obtuse variable, like, foo
. But... why would you do that??? The "framework" that I'm suggesting here to implement the Context API implies that the name of a given context variable also tells you exactly where that value came from. IMHO, this is incredibly valuable when troubleshooting.
Also, although there's nothing in this approach to enforce this idea, my concept is that:
If a given context variable "lives" in a given component, then it's only ever updated from that same component.
So, in the example above, the isLoggedIn
state variable "lives" in <DataLayer>
. This, in turn, means that any function that updates this variable should also "live" in <DataLayer>
. Using the Context API, we can pass/expose a function that will, ultimately, update that state
variable. But the actual work of updating that state
variable is only ever done from within the <DataLayer>
component.
This brings us back to the central setState()
functionality that's been a part of core React from Day 1 - but has been splintered by the proliferation of bolt-on global state-management tools like Redux. These tools suck that state-updating logic far away from the original component in which the value was first defined.
Conclusions
Look... I totally understand that if you're an established React dev working in legacy codebases, you probably already have existing state-management tools in place (probably, Redux). And I don't pretend that anything you've seen in these little demo examples will inspire you to go back to your existing team and beg them to rip out the state-management tools.
But I'm honestly struggling to figure out, with the Context API's native React functionality, why you would continue to shove those state-management tools, by default, into all of your future projects. The Context API allows you to share state (or even, values that don't natively live in state - like, functions) anywhere you want all down the hierarchy tree. It's not some third-party NPM package that I've spun up. It represents no additional dependencies. And it's performant.
Although you can probably tell from my illustration that I'm enamored of this solution, here are a few things for you to keep in mind:
The Context API is inherently tied to the
render()
cycle (meaning that it's tied into React's native life cycle). So if you are doing more "exotic" things with, say,componentDidMount()
orshouldComponentUpdate()
, it is at least possible that you might need to define a parent context in more than one place in the component. But for most component instances, it's perfectly viable to define that context only once-per-component, right inside therender()
function. But you definitely need to define those context references inside therender()
function. Otherwise, you won't receive future updates when the parent updates.If this syntax looks a wee bit... "foreign" to you, it might be because I'm imperatively throwing the contexts into a component-scoped
let
variable. I'm only doing this because you'll need those component-scopedlet
variables if you're referencing those values in other functions tied to the component. If you prefer to do all of your logic/processing right inside yourrender()
function, you can feel free to use the more "traditional" declarative syntax that's outlined in the React documentation.Another reason that I'm highlighting the imperative syntax is because, IMHO, the "default" syntax outlined in the React docs gets a bit convoluted when you want to use multiple contexts inside a single component. If a given component requires only a single parent context, the declarative syntax can be quite "clean".
This solution is not ideal if you insist on creating One Global Shared State To Rule Them All (And In The Darkness, Bind Them). You could simply wrap the whole damn app in a single context, and then store ALL THE THINGS!!! in that context - but that's probably a poor choice. Redux (and other third-party state-management tools) are better-optimized for rapid updates (e.g., when you're typing a bunch of text into a
<TextField>
and you're expecting the values to be portrayed onscreen with each keystroke). In those scenarios, the Context API works just fine - assuming that you haven't dumped every damn state variable into a single, unified, global context that wraps the entire app. Because if you took that approach, you would end up re-rendering the entire app on every keystroke.The Context API excels as long as you are keeping
state
where it "belongs". In other words, if you have a<TextField>
that requires a simplestate
value to keep track of its current value, then keep thestate
for that<TextField>
in its parent component. In other words, keep the<TextField>
's state where it belongs. I've currently implemented this in a React codebase with 30k+ LoC - and it works beautifully and performantly. The only way that you can "muck it up" is if you insist on using one global context that wraps the entire app.As outlined above, the Context API provides a wonderfully targeted way to manage shared state that is part of React's core implementation. If you have a component that doesn't need to share values with other components, then that's great! Just don't wrap that component's
render()
function in a context provider. If you have a component that doesn't need to access shared values from further up the hierarchy, then that's great! Just don't import the contexts from its ancestors. This allows you to use as much state management (or as little) as you deem necessary for the given app/component/function. In other words, I firmly believe that the deliberate nature of this approach isn't a "bug" - it's a feature.