loading...
Cover image for A Context API Framework for React State Management

A Context API Framework for React State Management

bytebodger profile image Adam Nathaniel Davis Updated on ・11 min read

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:

  1. Most professional devs consider "prop drilling" to be an unmanageable solution for large-scale applications.

  2. Most professional devs have come to see bolted-on state-management tools as a default must have.

  3. 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 an import 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 called this.sharedMethods that will hold references to any functions that I want to share down the hierarchy. This value is then passed into the value 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 why this.sharedMethods has no reference to helperFunctionToCallApi() - 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 excluding helperFunctionToCallApi() from the this.sharedMethods object, I've essentially preserved that function as being private.

  • Notice that the value for <UtilitiesContext.Provider> does not make any mention of state. This is because the <Utilities> component has no state that we want to share to ancestor components. (In fact, in this example, <Utilities> has no state whatsoever. So there's no point in including it in the value for <UtilitiesContext.Provider>.)

  • Above the component definition, I've defined a simple let variable as constant. Inside the render() 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 as this.constant. By doing it this way, I can refer, anywhere in the component, to constant.someConstantValue and constant 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 of constant.apiUrl will resolve to 'http://127.0.0.1/ when the callApi() function is called.

  • It's important to note that constant = ConstantsContext.Consumer['_currentValue'] is defined in the render() function. If we want this context to be sensitive to future state changes, we must define the reference in the render() function. If, instead, we defined constant = ConstantsContext.Consumer['_currentValue'] in, say, the constructor, it would not update with future state 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 and this.state are spread into the value of <DataLayerContext.Provider> Obviously, we're doing this because this components has both state variables and functions 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 trigger logIn() 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() or shouldComponentUpdate(), 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 the render() function. But you definitely need to define those context references inside the render() 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-scoped let 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 your render() 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 simple state value to keep track of its current value, then keep the state 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.

Posted on by:

bytebodger profile

Adam Nathaniel Davis

@bytebodger

React acolyte, jack-of-all-(programming)trades, full-stack developer

Discussion

markdown guide
 

I'm beginner, should I learn Redux or am l just focus on context API?

 

Good question, and I'll admit that I'm biased in this. But I'll give you my best possible assessment:

The first question is: Are you on a team that's already using React? If so, have they already implemented Redux? If they have, then you definitely need to get familiar with Redux - even if you won't be working directly in that part of the code.

But I assume you're asking because you're either not on a dev team, or you're not on a team that's currently using Redux. If that's the case, I will point out that Redux is a very common pattern in established React dev shops. So if you completely ignore Redux, you run the risk of handicapping your own prospects in the marketplace. Even if you hate Redux, or even if you believe that Redux is on the down-slope, you should still be familiar with the pattern.

Also, understand that Redux is heavily dependent upon reducers. And reducers are not a concept that is limited to Redux. You'll see reducers in many other places in JS. So if you haven't already, learn them and know them well. They could serve you well, even if you never touch Redux.

Finally, if you are blessed enough to be doing "green fields" work - where all of the key tech decisions were not already made months/years ago, then I would absolutely encourage you to focus heavily on the Context API and on shared Hooks (I have a later article on this site that illustrates how to use shared Hooks for shared state management).

Specifically, I've come to question the knee-jerk stance that many React devs take in favor of state-management tools/library. There was a time in React when it was almost unimaginable to consider building a large-scale app without some kind of bolt-on state management solution. Today, I firmly believe that this is no longer necessary.

If you find yourself blindly reaching for a state management solution in all your apps (whether that solution is Redux - or something else), I sincerely believe that you should reassess your development practices.

 

Dear Adams,
As a beginner, the statement you reply to me, I think it will be a guideline for my React Journey. It's more details with explanations then what I expect from you. It's mentorship from the community and privileges for me to get something from the community author.

Yes, I'm in the last part of React Bootcamps, and it's all about Redux and project. Could you give me resources to follow and best practices?

I wish you all the best and looking for your next articles!

Sincerely

I can't really give you a concise list of "resources and best practices" because there are sooooo many of them. Seriously. The internet is overflowing with great resources. (Yeah... I know, there are also a lot of not-so-great resources out there, but I can't possibly catalog all of them for you into great-or-not-so-great.)

But I can definitely give you one "best practice" that I sincerely hope you take to heart:

One of the themes that I try to highlight (over and over again) in my blogs is: never be a "fanboy" - of anything. In other words, don't ever latch onto a given technology and decide that it's your "favorite". And don't ever decide that you "hate" a given technology. Every technology is a tool in your tool belt. Craftsmen don't believe that the hammer is "good" or that the screwdriver is "bad". They understand that there's a time to use a hammer. And a time to use a screwdriver.

How does this apply to programming??

Well... let's look at Redux, for example. If you read enough of my articles, you'll soon realize that I pretty much "hate" Redux. Even in those areas where I feel Redux has benefits - I also believe it brings problems with it that negate those supposed benefits.

But you know what??? Those are my opinions. And you know what my opinions are worth?? Exactly what you paid for them!

You see... Redux is a tool. It may not be a tool that I prefer to use, but it's still a tool. And there are still some times when Redux might actually be the right tool for the job.

So... LEARN REDUX! And... learn other state management tools. And... learn how to build modern React apps without any state management tools. And... learn how to build apps without React at all!!

IMHO, the goal should be this:

Five years from now, when I've hired you onto my team, and we need to make a critical tech-stack decision, I'll want to hear your opinion. But I won't want to to know that you're making a recommendation based on some dogma that you learned 5 years ago in bootcamp. I'll want to know that you're making a balanced, reasoned recommendation based upon your own experience and based upon your own, honest assessment of the options in front of us.

If you can give a balanced, meaningful assessment of the tech options we're facing, then... you've "won". And you're an extremely valuable developer.

Too many devs, who've been doing this shit for years, can't say the same thing.

 

After using redux a few time I started to question why I was using react... I tried mobx also and still was not completely happy either. Then I took on a legacy react app that used no central state management at all... and actually it was small enough to not need it, it was fine and I got very used to the good old setState. My only grip was of course prop drilling but as I said it was small enough to not bother me too much.

When react 16 was released and introduced hooks the Context struck me straight away as the simple fix for my one issue and I’ve been using ever since, very happily.

I would also add that using react router and the url path can also negate the need for centralised state as it essentially takes on that role. That is for all of your public mutable state. And all private immutable state can be covered using the Context hook. I’m thinking of writing this up as a post very soon.

Thanks for sharing

 

An example of the (IMHO) flawed "page" mentality that I've seen with React router is that I'll be on a site/app that's using it, then I'll open the inspector and see all the API calls that are being made to supply the necessary data. Then I'll click somewhere in the app - which invokes a new "route", which is treated too-often by the dev team as an old-fashioned "page" - and then I'll watch in the inspector as all the previous API calls are repeated. Again, you don't have to design your React router app as such. But whenever I see that, I just kinda sigh. Cuz it's so lazy when you consider what we can do today with tools like React to build Single Page Applications.

 

Thanks for the feedback! I'd definitely be interested in seeing that post if you write it up. It's nice to hear that I'm not the only one with these thoughts. Sometimes, when working/talking with other "React types", I get the feeling that I'm on an island with these critiques.

I'm kinda neutral on hooks right now. I might "come around" to them later. But my main gripe with them is that, the way they were implemented (whereby there's no "easy" way to call them from within classes), they sorta become an all-or-nothing proposition. And I know... some people are thinking "That's why you should just abandon classes and use hooks exclusively!" But that's not really practical for any kinda legacy app. But this isn't a diatribe against hooks. Just a kinda critique about how I haven't been able to blindly embrace them as some devs have.

Re: React router, I see it as an 80%-90% "net good". My only problem with router is that I've seen too many React apps where they use the router as an excuse to build their apps in a very old-school, circa 2002, style. What I mean by this is they start to see their app in terms of "pages". And then they write logic that, like the server-side scripting apps of old, will recreate the entire state based on every new "page". Of course, you don't have to use React router in this way. But it's my only real "problem" with apps that are heavy on it.

Cheers!