With the addition of the React Context API in React’s 16.3.0 release, many articles have been written about leveraging the API for global state management. This has only been exacerbated with the Hooks API being released, giving us much-needed tools like
useContext. Many developers are now faced with a new option to evaluate when deciding how to approach global state management in their projects. Luckily, this article is here to help! Keep reading to learn more about the use cases that fit this API and what to do when your app outgrows the use of Context API.
What is the Context API good for?
There are multiple hints given in the official docs, like the following one:
“Context is designed to share data that can be considered “global” for a tree of React components, such as the current authenticated user, theme, or preferred language.”
Notice what all of the example use cases have in common? None of those cases should change value frequently during a single session. Recognizing that brings us a bit closer to the more specific recommendation – use the Context API to store the data that does not change frequently. If the requirements of your project meet that criteria, you’re good to go. If you’re not sure, let’s dig deeper into this and find out why the frequency of updates matters so much.
Propagation of Updates
When a context provider updates, all the components that consume it will render. That’s it, pretty straightforward. More or less behaves the same way as if the updated value was passed through props, right? Well, there is a difference – with props, we can easily bail out of rendering by using React.memo. Unfortunately, it’s not so easy with the Context API. Let’s take a look at an example of this:
Context and its consumers have a context provider that holds data about the current user and the
shoppingCart. Somewhere down the component tree, there’s a component interested in
shoppingCart so it can render the shopping cart items. Also, there’s a component that displays the user profile, so it needs the user object. What happens if a user changes their profile picture?
Context update causes all consumers to render
Profile component renders as expected, but we didn’t want the
Cart component to render due to a change in the user object. What happened? As we said previously, context updates make all of their consumers render. It does not matter if the slice of context your component was interested in didn’t change.
This might not be a problem for every app – after all, React is pretty good at making sure that DOM updates only when really needed. Regardless, we still need to make sure that our render functions are running fast – frequent updates lead to frequent rendering, and if render functions are slow that will leave the users with a frozen UI. So, how do we prevent this from happening?
Control over Rendering
There are multiple approaches we can take here, but on the most basic level we are doing one of the following:
- Removing the rendering causes
- Optimizing rendering functions
Let’s consider removing the rendering causes first – in the example above, we have one context provider which holds data that has
different reasons to change. Why would a single user interaction cause an update to both the
shoppingCart objects? How about splitting the context providers so they only hold cohesive data?
Bailout of rendering by splitting context. This works as long as the data stored in separate context providers is unrelated and does not change for the same reasons. If you notice that you frequently need to consume multiple context providers in your components or that some user interactions result in changes being made to more than one context provider, you might want to consider abandoning this pattern.
Since a context provider must be an ancestor of a component for it to be able to consume it, you might end up with a variety of context providers stacked on top of each other. That is not a problem on its own, but if there are dependencies between the providers you might run into problems like circular dependency. Adding new providers could be a challenge since you would need to find the best place in the existing provider hierarchy.
The alternative approach is based on minimizing the cost of a render. As we discussed previously, heavy render functions are a bigger performance issue than frequent lightweight renders. React gives us all the tools we need, but we still have to do a couple of steps:
- Extract the context consumer part of the component to a parent component
- Extend the props of the component so the parent component can send the context values down as props
- Wrap the component with
React.memoand specify the exact props that should trigger a new render if changed
Bailout of rendering by checking if props are equal. Every time
CartWrapper will render but this is no longer a problem. It only contains code needed to read the
shoppingCart object from context and pass it down to
Cart as props. We wrapped
React.memo so it only renders when
shoppingCart actually gets updated.
This pattern gives us fine-grained control over rendering at the cost of adding indirection to our codebase. Also, it’s quite easy to accidentally break memoization since there are no runtime errors being recorded when an honest mistake happens. With that in mind, let’s reflect on what we discussed so far.
Now that we know how context updates propagate to components, we hope that the original recommendation makes more sense: React Context API is best used for global data that does not change frequently. Depending on your project requirements, this might just be enough for your needs.
We also demonstrated a couple of patterns that can be used to achieve control over rendering. They come with a big caveat though: performance optimization tricks like these can be brittle. Since there is no immediate feedback like runtime errors, a seemingly unrelated change can easily circumvent the whole setup and make components render on every context change again. Automated performance audits can help with this – be sure to check Gimbal out. It’s very important to base decisions about performance improvements on real measurements – that way it’s less likely you would succumb to the fallacy of premature optimization.
If maintaining code needed to get the context to behave becomes too complex, you might want to look into some of the popular state management libraries. At the cost of increased bundle size and some learning effort, they’ll provide built-in solutions for the problems outlined above. More complex solutions will have some other added benefits as well. Redux will have you define mapping functions that are used to determine what slice of global store your components need. Recoil keeps state in atoms your components can subscribe to using hooks. MobX uses observables under the hood.
When evaluating a state management library, our suggestion is to start by thinking about the problem you are trying to solve. Does your application have measurable performance issues due to uncontrolled re-rendering? Libraries like MobX and Recoil are focused on solving that problem. But what if you have subtle bugs caused by accidental mutations and fragmented business logic? Redux will help with that. Also, there are more extreme tools like XState that can help you take control of application state changes in a formal way.
- React Navigation and Redux in React Native Applications
In React Native, the question of “how am I going to navigate from one screen…
- Ext JS to React: Handling Application State with Redux
This is part of the Ext JS to React blog series. You can review the…