A few days ago, I wrote a post about a workaround/hack that I've been using in React to pass around components' state variables and functions. I knew that my approach was by-no-means perfect, so I openly solicited feedback from the community - and they delivered.
What I'm going to discuss/illustrate here is (IMHO) a far better approach to shared state in React. This approach does not use any third-party or bolt-on state-management libraries. It uses React's core constructs to address the "challenge" of prop drilling. Specifically, I'm talking about React's Context API.
The Context API's been available in React for a long time. However, until about 18 months ago (when React 16.3 was released), the Context API was listed as "experimental". It was bundled in the core React library, but there were expected changes in the syntax that weren't solidified until version 16.3.
Because of that scary "experimental" tag, and because, quite frankly, I found the previous documentation to be somewhat obtuse, I never really paid too much attention to the Context API. I knew it was there, but any time I tried to really leverage it, it just didn't seem to be working the way that I wanted it to.
But my previous post - which contained a lot of angst about the elitist React dogma that surrounds Redux - got me to reassess the Context API.
In full disclosure, there's also been some prominent discussion that the Context API is not appropriate for "high-frequency updates". Personally, I think that's a pile of BS (and I'll explain why below). But it's worth noting that some people would use this as a reason to dismiss the Context API as a valid solution (or as a reason to cling to their beloved Redux).
The biggest thing that I'll be addressing here is called prop drilling. It's the idea that, in a "base" React implementation, you probably have a hierarchy of components. Each component can have its own values (i.e., its own state). If a component at the bottom of the hierarchy tree needs access to something from the top of that same tree, the default React solution is to pass those values - via props - down to the bottom component.
But a potential headache arises if there are many layers between the higher-level component which holds the desired value, and the bottom-level component which needs access to that value. If, for example, there are 100 components "between" the higher-level component and the bottom-level component, then the required values would have to be passed through each of those 100 intermediary components. That process is referred to as prop drilling.
In most React shops, the answer has been to reach for a state-management solution to bolt onto the application. The most common solution has been Redux, but there are many others. These tools create a shared cache that can then be accessed by any component in the app, allowing devs to bypass the whole prop drilling "problem". Of course, there are many potential problems that can be introduced by state-management libraries, but that's a topic for another post...
Let me start by saying that this post isn't going to show you some radically-new, previously-undiscovered technique. As stated above, the Context API's been available in experimental mode for many years. Even the "official" version was solidified with React 16.3, which came out ~18 months ago (from the time that this was written).
Furthermore, I'll gladly admit that I gained clarity and inspiration from several other posts (at least one of them was right here on DEV
) that purport to show you how to use the Context API. I'm not reinventing any wheels here. And I don't claim to be showing you anything that you couldn't grok on your own by googling through the official React docs and the (many) tutorials that are already out there. I'm only doing this post because:
So with all of that in mind, imagine that we have a very basic little React application. Even modest applications tend to employ some kind of component hierarchy. So our application will look like this:
<App>
↓
<TopTier>
↓
<MiddleTier>
↓
<BottomTier>
Remember: The central "problem" that we're trying to solve is in regard to prop drilling. In other words, if there is a value/function that resides in the <App>
component, or in the <TopTier>
component, how do we get it down to <BottomTier>
?
(Of course, you may be thinking, "For an app that's this small, it would be better practice to simply pass the value/function down through the hierarchy with props." And, for the most part, you'd be right. But this is just a demo meant to illustrate an approach that could be done on much larger apps. In "real" apps, it's easy for the hierarchy to contains many dozens of layers.)
In the past, if a developer didn't want to pass everything down through props, they'd almost always reach for a state-management tool like Redux. They'd throw all the values into the Redux store, and then access them as-needed from any layer of the hierarchy. That's all fine-and-good. It... works. But compared to what I'm about to show you, it's the equivalent of building a sandcastle - with a bulldozer.
Here's the code for all four of the components in my demo app:
<App>
(App.js)```javascript import React from 'react'; import TopTier from './components/top.tier';
export const AppContext = React.createContext({});
export default class App extends React.Component { constructor(props) { super(props); this.state = { logToConsole: this.logToConsole, myName: 'Adam', theContextApiIsCool: true, toggleTheContextApiIsCool: this.toggleTheContextApiIsCool, }; }
logToConsole = (value) => { console.log(value); };
render = () => {
return (
toggleTheContextApiIsCool = () => { this.setState((previousState) => { return {theContextApiIsCool: !previousState.theContextApiIsCool}; }); }; } ```
Nothing too exotic here. For the most part, it looks like any "normal" <App>
component that could be launching nearly any kind of "industry standard" React application. There are only a few small exceptions:
<App>
component.GlobalContext
or SharedState
, because I don't want this context to hold all the state values for the whole damn application. I only want this context to refer, very specifically, to the values that are resident on the <App>
component. This will be critical later when I discuss performance (rendering) considerations.state
object also has references to the component's functions. This is critical if we want components further down the hierarchy to be able to call those functions.render()
function calls <TopTier>
, that component is wrapped in <AppContext.Provider>
. <TopTier>
(/components/top.tier.js)```javascript import MiddleTier from './middle.tier'; import React from 'react';
export const TopTierContext = React.createContext({});
export default class TopTier extends React.Component { constructor(props) { super(props); this.state = {currentUserId: 42}; }
render = () => {
return (
This is similar to the <App>
component. First, we're creating a context that's specific to the <TopTier>
component. Then we're wrapping the render()
output in <TopTierContext.Provider>
.
<MiddleTier>
(/components/middle.tier.js)```javascript import BottomTier from './bottom.tier'; import React from 'react';
export default class MiddleTier extends React.Component { render = () => { return (
This is the last time we'll be looking at this component. For the purpose of this demo, its only real "function" is to be skipped over. We're gonna show that, with the Context API, we can get the values from <App>
and <TopTier>
down to <BottomTier>
without having to explicitly pass them down the hierarchy through props.
<BottomTier>
(/components/bottom.tier.js)```javascript import React from 'react'; import {AppContext} from '../App'; import {TopTierContext} from './top.tier';
export default class BottomTier extends React.Component { render = () => { const {currentValue: app} = AppContext.Consumer; const {currentValue: topTier} = TopTierContext.Consumer; app.logToConsole('it works'); return (
OK... there's some fun stuff happening in this component:
AppContext
and TopTierContext
, because we'll want to leverage variables/functions that reside in those components._currentValue
out of AppContext.Consumer
and TopTierContext.Consumer
. This allows us to grab the values from those contexts with an imperative syntax.render()
returns anything, we directly invoke app.logToConsole()
. This demonstrates that we can directly call functions that "live" in the <App>
component.return
, we access a state variable directly from <App>
when we display {app.myName}
.<TopTier>
when we display {topTier.currentUserId}
.<div>
s will dynamically display-or-hide a message based on <App>
's theContextApiIsCool
state variable.theContextApiIsCool
in the <App>
component by calling {app.toggleTheContextApiIsCool()}
.If you'd like to see a live version of this, you can find it here:
https://stackblitz.com/edit/react-shared-state-via-context-api
There are none! It's a flawless solution!!!
(Just kidding. Well... sorta.)
Global-vs.-Targeted State Storage
When you first start reaching for state-management solutions, it's natural to think:
I just want ONE state store (to bring them all, and in the darkness, bind them).
OK, I get that. I really do. But I always chuckle a little inside (or directly in someone's face) when I hear them preach about avoiding needless dependencies in their apps - and then they dump their favorite state-management tool into damn-near every component across their entire app. Repeat after me, people:
Shared state-management tools are the definition of dependency injection.
If you want to proselytize to me all day about the dangers of entangling dependencies, then fine, we can have an intelligent conversation about that. But if I look at your apps, and they've got a state-management tool littered throughout the vast majority of your components, then you've lost all credibility with me on the subject. If you really care about entangling dependencies, then stop littering your application with global state-management tools.
There's absolutely a time and a place when state-management tools are a net-good. But the problem is that a dev team decides to leverage a global state-management solution, and then (Shocking!) they start using it globally. This doesn't necessarily "break" your application, but it turns it into one, huge, tangled mess of dependencies.
In the approach I've outlined above, I'm using shared state-management (via React's built-in Context API) in a discrete-and-targeted way. If a given component doesn't need to access shared state, it simply doesn't import the available contexts. If a component's state never needs to be queried by a descendant, we never even bother to wrap that component's render()
output in a context provider. And even if the component does need to access shared state, it has to import the exact contexts that are appropriate for the values that it needs to perform its duties.
Of course, you're not required to implement the Context API in the manner I've outlined above. You could decide to have only one context - the AppContext
, which lives on the <App>
component, at the uppermost tier of the hierarchy. If you approached it in this way, then AppContext
would truly be a global store in which all shared values are saved-and-queried. I do not recommend this approach, but if you're dead-set on having a single, global, state-management solution with the Context API, you could do it that way.
But, that approach could create some nasty performance issues...
Performance Concerns During High-Frequency Updates
If you used my approach from above to create a single, global store for ALL state values, it could drive a sizable application to its knees. Why??? Well, look carefully at the way that we're providing the value
to the <AppContext.Provider>
:
javascript
// from App.js
render = () => {
return (
<AppContext.Provider value={this.state}>
<TopTier/>
</AppContext.Provider>
);
};
You see, <AppContext.Provider>
is tied to <App>
's state. So if we store ALL THE THINGS!!! in <App>
's state (essentially treating it as a global store), then the entire application will re-render any time any state value is updated. If you've done React development for more than a few minutes, you know that avoiding unnecessary re-renders is Item #1 at the top of your performance concerns. When a React dev is trying to optimize his application, he's often spending most of his time hunting down and eliminating unnecessary re-renders. So anything that causes the entire damn application to re-render in rapid succession is an egregious performance flaw.
Let's imagine that <BottomTier>
has a <TextField>
. The value of the <TextField>
is tied to a state variable. And every time the user types a character in that field, it requires an update to the state value upon which that <TextField>
is based.
Now let's imagine that, because the dev team wanted to use my proposed Context API solution as a single, global store to hold ALL THE THINGS!!!, they've placed the state variable for that <TextField>
in <App>
's state (even though the <TextField>
"lives" at the very bottom of the hierarchy in <BottomTier>
). This would mean that, every single time the user typed any character into the <TextField>
, the entire application would end up being re-rendered.
(If I need to explain to you why this is bad, then please, stop reading right now. Step away from the keyboard - and burn it. Then go back to school for a nice, new, shiny degree in liberal arts.)
So is this the Achilles' Heel that invalidates any use of the Context API for shared state-management and sends us all running back to Redux??
Of course not. But here's my (unqualified) advice: If your little heart is dead-set on having The One State Store To Rule Them All, then... yeah, you should probably stick with your state-management package-of-choice.
I reserve the right to update my opinion on this in the future, but for now, it feels to me that, if you insist on dumping all of your state variables into a single, global state-management tool, then you should probably keep using a state-management package. Redux, specifically, has deployed many optimizations to guard against superfluous re-renders during high-frequency updates. So kudos to them for having a keen eye on performance (no, really - a lotta people a lot smarter than me have poured copious hours into acid-proofing that tool).
But here's the thing:
Why are you obsessed with the idea that state-management must be a global, all-or-nothing solution??
As I've already stated:
globalStateManagement === massiveDependencyInjection
The original idea of React was that state resides in the specific component where that state is used/controlled. I feel that, in many respects, the React community has progressively drifted away from this concept. But... it's not a bad concept. In fact, I would (obviously) argue that it's quite sound.
So in the example above, I would argue that the state variable that controls our proposed <TextField>
value should "live" in the <BottomTier>
component. Don't go lifting it up into the upper tiers of the application where that state variable has no canonical purpose (or, we could say, no context).
Better yet, create a wrapper component for <TextField>
that will only manage the state that's necessary to update the value when you type something into that field.
If you do this, the Context API solution for shared state-management works beautifully. Even in the demo app provided above, it's not too difficult to see that certain state values simply don't belong in AppContext
.
A Boolean that indicates whether-or-not the user is logged in might comfortably belong in AppContext
. After all, once you've logged in/out, there's a good chance that we need to re-render most-or-all of the app anyway. But the state variable that controls the value of a <TextField>
that exists, at the bottom of the hierarchy, in <BottomTier>
??? That really has no business being managed through AppContext
.
If it's not clear already, I believe that this "feature" of the Context API approach is not a bug or a flaw. It's a feature. It keeps us from blindly dumping everything into some big, shared, global bucket.
Tracking Down State Changes
If you're using a state-management tool, you might be thinking:
State variables can, theoretically, be updated from many different sources. My Beloved State Management Tool allows me to ensure that those changes always pass through a single gateway. And thus, my troubleshooting is easier and my bugs are less frequent.
In the demo I've provided, there are some concerns that might jump out at you. Specifically, any component that imports AppContext
, in theory, has the ability to update the state variables in the <App>
component. For some, this invokes the nightmares that they might have had when troubleshooting in a framework that supported true two-way data binding.
So if these state-altering hooks can be littered anywhere throughout the app, doesn't this Context API approach make my troubleshooting life hell??
Well... it shouldn't.
Let's look at the toggleTheContextApiIsCool()
function in the <App>
component. Sure, it's theoretically possible that any component could import AppContext
, and thus, invoke a state change on <App>
's theContextApiIsCool
variable.
But the actual work of updating the state variable is only ever handled inside the <App>
component. So we won't always know who invoked the change. But we will always know where the change took place.
This is really no different than what happens in a state-management tool. We import the references to the state-management tool (anywhere in the application), and thus, any component can, theoretically, update those state variables at will. But the actual update is only ever handled in one place. (In the case of Redux, those places are called reducers and actions.)
Here's where I think that the Context API solution is actually superior. Notice that, in my demo app, the theContextApiIsCool
variable "lives" in the <App>
component. Any functions that update this value also "live" in the <App>
component.
In this little demo, there is but a single function with the ability to setState()
on the theContextApiIsCool
variable. Sure, if we want to invoke that function, we can, theoretically, do it from any descendant in the hierarchy (assuming that the descendant has already imported AppContext
). But the actual "work" of updating theContextApiIsCool
all resides in the <App>
component itself. And if we feel the need to add more functions that can possibly setState()
on the theContextApiIsCool
variable, there is only one logical place for those functions to reside - inside the <App>
component.
What I'm talking about here is a component's scope of control. Certain state variables should logically be scoped to the component where those variables are pertinent. If a given state variable isn't pertinent to the given component, then that state variable shouldn't "live" in that component. Furthermore, any function that alters/updates that state variable should only ever reside in that component.
If that last paragraph gets your hackles up, it's because many state-management tools violate this simple principle. We create a state variable - and then we chunk it into the global state-management store. This, in effect, robs that variable of context.
Imperative-vs.-Declarative Syntax
You might look at my demo app and feel a bit... bothered by some of the syntax I've used. Specifically, if we look at the <BottomTier>
component, you may (as a "typical" React developer), be a wee bit bothered by lines like these:
javascript
const {_currentValue: app} = AppContext.Consumer;
const {_currentValue: topTier} = TopTierContext.Consumer;
app.logToConsole('it works');
Please... don't get too hung up on this syntax. If you look at most of the Context API tutorials/demos on the web (including those on the React site itself), you'll quickly see that there are plenty of examples on how to invoke this functionality declaratively. In fact, as far as I could tell, it looks as though damn-near all of the tutorials feature the declarative syntax. So don't dismiss this approach merely because I chose to toss in some "imperative voodoo".
I'm not going to try to highlight all of the declarative options for you in this post. I trust your epic googling skills. If you're wondering why I chose this particular syntax, trust me: I love many aspects of React's inherent declarative ecosystem. But sometimes I find this approach to be onerous. Here's my logic:
It seems that damn-near every example I could find on Context API functionality (including those at https://reactjs.org/docs/context.html) seem to focus almost exclusively on the declarative syntax. But the "problem" is that the declarative syntax is usually implicitly tied to the render()
process. But there are times when you want to leverage such functionality without depending upon the rendering cycle. Also (and I admit that this is just a personal bias), I often feel it's "ugly" and difficult to follow when demonstrators start to chunk a whole bunch of basic JavaScript syntax into the middle of their JSX.
OK... I'll admit that maybe, just possibly, the title on this post is a weeee bit "click-bait-y". I don't imagine that any of you are going to go into work tomorrow morning and start yanking out all of your legacy state-management code. But here are a few key seeds that I'd like to plant in your brain (if the narrative above hasn't already done so):
I'd truly appreciate any feedback on this - positive or negative. What have I blatantly overlooked?? Why is Redux (or MobX, or any state-management library) far superior to the Context API solution that I've proposed??
On one hand, I'll freely admit that I've written this post in a fairly-cocksure fashion. Like I've discovered The One True Way - and all you idiots should just fall in line.
On the other hand, I'll humbly acknowledge that I didn't really start ruminating on this potential approach until yesterday. So I'm glad for any of you to give me hell in the comments and point out all the stupid assumptions I've made. Or to point out any of the horrific flaws in the Context API that I've either glossed over - or am totally unaware of.
I was wrong before. Once. Back in 1989. Oh, man... that was a horrible day. But who knows?? Maybe I'm wrong again with this approach?? Lemme know...