Throw Out Your React State-Management Tools

Adam Nathaniel Davis - Feb 23 '20 - - Dev Community

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.

Some Context on the 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).

Features of the Context API

  1. It's no longer experimental. It's been available for years, but it's now graduated to the "big leagues". This is important to note because the syntax did in fact change between the "experimental" and "official" versions.

  2. It's part of core React. So there's no need to tack on a pile of additional packages to manage/share your state.

  3. It has a minimal footprint. In the examples I'll show below, you'll see that you can leverage the Context API with very few extra lines of code. This is in stark contrast to Redux, which is known (even amongst its biggest fanboys) to require a massive amount of "boilerplate" code.

  4. It can be used in a very efficient, targeted manner. Just like any solution for passing/sharing state values, it's possible to muck the whole system up by creating a monolithic (and gargantuan) Global Store that will drag your application to its knees. But this is easily avoidable with the Context API with a modicum of architectural planning. You can also choose, in a very targeted fashion, which values are stored, at what level of the application they're stored, and which descendant components have access to the Context values. In other words, you don't have to put All The Things!!! in the Context store. And once something is stored in a Context store, it doesn't have to be available to All The Things!!!.

The Problem

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

The Setup

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:

  1. This blog is basically my own, free, self-administered therapy. And it helps me to codify my thoughts by putting them into a (hopefully) coherent document.

  2. There are a few small details of my preferred implementation that are probably a little unique, compared to the other demos you might see.

  3. This post (and the approach I'm about to outline) is a direct follow up to my previous post titled "Why Is This An 'Anti-Pattern' in React???"

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)

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 (
         <AppContext.Provider value={this.state}>
            <TopTier/>
         </AppContext.Provider>
      );
   };

   toggleTheContextApiIsCool = () => {
      this.setState((previousState) => {
         return {theContextApiIsCool: !previousState.theContextApiIsCool};
      });
   };
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Before the class declaration, we're creating a constant that's a new instance of React's built-in context handler. The new context will be specific to the <App> component.

  2. Notice that I didn't name the context something like 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.

  3. Aside from housing some basic scalar values, the 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.

  4. Before the render() function calls <TopTier>, that component is wrapped in <AppContext.Provider>.

<TopTier> (/components/top.tier.js)

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 (
         <TopTierContext.Provider value={this.state}>
            <div style={{border: '1px solid green', margin: 20, padding: 20}}>
               This is the top tier.
               <MiddleTier/>
            </div>
         </TopTierContext.Provider>
      );
   };
}
Enter fullscreen mode Exit fullscreen mode

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)

import BottomTier from './bottom.tier';
import React from 'react';

export default class MiddleTier extends React.Component {
   render = () => {
      return (
         <div style={{border: '1px solid green', margin: 20, padding: 20}}>
            This is the middle tier.
            <BottomTier/>
         </div>
      );
   };
}
Enter fullscreen mode Exit fullscreen mode

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)

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 (
         <div style={{border: '1px solid green', margin: 20, padding: 20}}>
            <div>This is the bottom tier.</div>
            <div>My name is {app.myName}</div>
            <div>Current User ID is {topTier.currentUserId}</div>
            <div style={{display: app.theContextApiIsCool ? 'none' : 'inherit'}}>
               The Context API is NOT cool
            </div>
            <div style={{display: app.theContextApiIsCool ? 'inherit' : 'none'}}>
               The Context API is cool
            </div>
            <button onClick={() => app.toggleTheContextApiIsCool()}>
               Toggle `theContextApiIsCool`
            </button>
         </div>
      );
   };
}
Enter fullscreen mode Exit fullscreen mode

OK... there's some fun stuff happening in this component:

  1. We import references to AppContext and TopTierContext, because we'll want to leverage variables/functions that reside in those components.

  2. We destructure _currentValue out of AppContext.Consumer and TopTierContext.Consumer. This allows us to grab the values from those contexts with an imperative syntax.

  3. Before the render() returns anything, we directly invoke app.logToConsole(). This demonstrates that we can directly call functions that "live" in the <App> component.

  4. Inside the return, we access a state variable directly from <App> when we display {app.myName}.

  5. On the next line, we access a state variable directly from <TopTier> when we display {topTier.currentUserId}.

  6. The next two <div>s will dynamically display-or-hide a message based on <App>'s theContextApiIsCool state variable.

  7. Finally, we show the user a button that allows them to toggle the 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

The "Gotcha's" and "Downsides" to This Approach

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>:

   // from App.js
   render = () => {
      return (
         <AppContext.Provider value={this.state}>
            <TopTier/>
         </AppContext.Provider>
      );
   };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 theContextApiIsCoolvariable, 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:

const {_currentValue: app} = AppContext.Consumer;
const {_currentValue: topTier} = TopTierContext.Consumer;
app.logToConsole('it works');
Enter fullscreen mode Exit fullscreen mode

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.

So... Are You Ready To Throw Out Your State-Management Tools-of-Choice??

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):

  • The Context API can actually be pretty powerful. I will raise my hand and admit that, as a React developer now for the last 4-or-so years, I really hadn't given it much serious consideration. But now I'm starting to think that was a mistake.

  • State-management tools are awesome tools - but I no longer believe they should be blindly implemented in all React codebases - and on all new React projects. In fact... I'm starting to think that, in a perfect world, implementation of those tools would be the exception - not the rule.

  • A monolithic, global state store is, in many cases, a lazy and sub-optimal solution. Look... I get it. I've been the first one to blindly assume that state-management is a must-have in any "serious" React application (even if my strong preference has been for MobX, and not for Redux). But my thinking is definitely evolving on this. Global stores are, essentially, dependency-generators. And if you're not going to insist upon a global store, then why are you adamant about falling back on an additional set of libraries, when React's native Context API functionality might easily serve your purpose???

So What Is Your Verdict??

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

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player