Some useful things to know about React Context

React

IntroDUCTION

 

React Context API is an alternative to state management librairies like Redux or MobX, which allows components within a context provider to share the same state, thus avoiding prop drilling. The upsides for React Context is that it is rather simple to use and more flexible as it is not necessarily global to your application. However, there are some features from other state management libraries that React Context API lacks, such as selectors for instance, which can result namely in performance issues. If you are familiar with these other state management libraries but haven't yet much experience with React Context or if you have never used a state management library, I'll explain in this article what I think you should know and some tips to improve your developper experience, based on my personal experience and the main issues I had to go through while using React Context. Before diving in, if you haven't already read it, I would recommend you to read this great article from Kent C.Dodds which explains the basics of how contexts work and provides some useful tips.

 

A change of the prop value will trigger a re-render of all context consumers

 

One of the main problem that can arise from the use of React Context is that each time the value of the context provider changes, every component that consumes it will re-render, even though it may not need to do so because it doesn't access the value that has been updated. I'll explain this more in detail with an example and we'll see how you can solve this problem should it be one.

Let's consider the simple use case of an app with two counters and two buttons to increment them. Keep in mind that all of the examples in this article are over engineered, you would not normally need to use a context in that spot, these are merely examples to help you understand better. We will use in this example a context to expose the values and setters of two counters.

Let's start by creating a context provider that will initialize the values of our counters with useState and return a provider exposing these values.

 

  • We do not access directly the context but use a custom hook to do so, which allows us for instance to prevent the use of useContext outside of the provider, which we can do by providing undefined as default value for our context. This is very useful and may prevent some bugs.
  • We memoize the provider value so that it doesn't create a new object each time our provider is re-rendered, which would trigger an update of all of the context's consumers.

 

Now let's create our counter components:

 

Capture_decran_2021-04-05_a_10.36.13

 

The other counter is similar to this one. Once we have created our components, let's add them to our application:

Capture_decran_2021-04-05_a_10.37.38

We now have an app with two counters, each incrementing one value. The issue here is that every time that either count1 or count2 will be incremented, both our counters will be updated since our context prop value will change. This results in unnecessary re-renders as the first counter does not need to re-render after the value of count2 is updated. Our application being pretty basic, this will not be a problem here but as it grows more complex, we may end up with a real performance problem.

 

Split your logic into several contexts

 

Contexts do not need to be global to your application and may very well be applied to one part of your application, so your provider should be as near as possible from the components accessing its value. Furthermore, if there are parts in your application logic that are independent, you could split your context into several contexts so that components that consume the value of one context do not update when the value of another one is updated. This will enhance your application's performance but also its maintainability as your contexts provider will now have a single responsibility.

If we look back at our first example, the logic of the two counters is completely independent and as such could be split into two contexts. We would then have two context providers, each one managing the value of a single counter.

Capture d’écran 2021-05-10 à 20.38.59

This way, the first counter will not be a consumer of the second counter context provider and thus will not re-render when the value of count2 is updated.

Now that our contexts have been split, each of the two contexts is being consumed by only one counter and therefore the provider can be moved below in the app tree so that it wraps only the components that actually need to be wrapped by it. Let's do that in our example:

Capture d’écran 2021-05-10 à 20.40.23

If you have several contexts, ideally their logic should be totally independent otherwise they will be harder to maintain and there is a chance that you store the same data in several places which often results in bugs when you mutate that data.

If for some reason you still need to access both your contexts in some components, you can create a custom hook for doing just that:

Capture d’écran 2021-05-10 à 20.42.53

Have separated contexts for your state and your setters or your dispatch

You can use different providers for your values and your setters, for instance :

Capture d’écran 2021-05-10 à 20.48.41

By doing so, your components that only mutate your data but do not need to access the state won't update when the state is mutated. This optimization will result in a more complex API, hence you should not necessarily do this unless you have performance issues. If you want to know more about this pattern, check out this article.

 

Memoize YOUR COMPONENTS

Another way to solve context related performance issues is to memoize your components that consume your context so that they re-render only when it is needed. However, you should not automatically memoize every component consuming your context. This would result in your code being less maintainable and more complex, plus it may not be a true optimization. If you wonder when exactly you should memo, I would recommend you to look at the following articles: usememo and usecallback, before you memo

If you are experiencing real performance issues and components that are costly to render and that actually re-render more than they need to, then you can memoize them using React.memo. Let's go back to our example and create a counter that is not coupled with our context's logic, that takes the counter value and its setter as props using React.memo:

Capture_decran_2021-04-06_a_08.52.37

Now we can adapt our counters component using this counter component:

Capture_decran_2021-04-06_a_08.52.28

Note that since we pass a function as a prop to our counter component, we need to use useCallback so that it doesn't re-render every time the Counter2 component is re-rendered.

Components using React.memo re-render only when the value of their props changes, so an update of count2 will trigger a re-render of our Counter1 component but not of the Counter component.

The other upside is that we have decoupled our graphic components from our application logic :the counter component is now agnostic and is not related to a specific context. This allows us to reuse this component for the two counters in our application and it also makes out counter component more maintainable and easier to test as we don't have to wrap the component in a provider when writing our integration test. Even though optimization is not always needed and you should consider other options before using React.memo, I think it is a good practice to create components that only consume your context and then pass the values to another ones that can be used without any context.

 

Be very careful about derived state

 

Let's add a new feature to our application and say that we want to display the sum of our counts. This value can be computed based on the values of our state : this is the definition of "derived state". If we needed to access this value in several components, we may want it to be accessible via our context. With other state management librairies such as Redux, you would create a selector computing your derived data and if the computation is heavy, you could memoize your selector using for instance reselect for redux. But there are no selectors in the React context api and it is not explained in the documentation how you should handle it.

 

what you should absolutely not do

We're going to create a new state in our provider for the value of the sum. Our context provider component will then look like this:

Capture_decran_2021-04-06_a_08.48.41

Creating a new state for totalCount must seem like something you would not do and it is not of much use in our example, but it can be easy to include in your state derived data and you may not even be aware of it. For instance, if we used useReducer instead of useState and had all of our state in a single object, adding a key totalCount to this object would be an easier mistake to make but in the end it would mean exactly the same thing. The reason I'm talking about this subject is because I have encountered the very same issue several times already and it very often results in painful bugs.

So, back to our example : now that we have added totalCount to our state we need to update it each time we increment one of our counters :

Capture_decran_2021-04-06_a_08.55.16

The problem here is that we had to change both our increment functions and if we add new ways of modifying our counters, such as a reset button for instance, we'll need to update the total count there as well. As the logic our application grows more complex, for instance if you have a context using useReducer, it will become hard to maintain and we don't have any insurance that our state will stay synced. Because we have duplicated the source of truth for our total count, meaning that the value can be obtained both by adding count1 and count2 or by accessing the totalCount value stored in our state, it becomes possible that these two values fall out of sync, which would result in bugs in our application. You should never add values to your state that can be computed based on the other ones already there. You can read more about this topic here.

 

Computing derived state in provider

 

The solution I have come up with to deal with derived state is to compute it directly in the context provider and then to add it to the provider's value.

Capture_decran_2021-05-05_a_20.51.42

This way, we compute the value of totalCount only when the value of one of the counts changes and we are guaranteed that the value is always correct. Note that we compute directly the total count when defining value but if there were other values in our state and we didn't want to compute it every time, we could compute it beforehand:

 

Capture_decran_2021-05-05_a_20.54.59

 

An alternative solution is to create a hook that computes the value you need but it has several downsides :

  • You can't access the value by calling useContext as it is not included in your provider value
  • If you have several values that are computed based on the values of your state, you need to create one hook for each of these values if you don't want to compute them all every time you need only one.
  • Every time you call that hook, you compute the value even though it may not have changed

The performance issues here are most of the time not meaningful so you should not be too concerned about it but I find that this pattern results in a more complex and painful use of your custom context api so I'd still recommend to include your derived state in your provider's value.

 

Conclusion

React context provider triggers an update of all its consumers when its value prop changes and that may cause performance issues. Still, most of the time things will work fine and you shouldn't make your code more complex for a minimum impact on your performance application. If these issues have however a serious impact on the end user, there are several things you can do such as memoize your components or split your context to fix them. Remember also that you shouldn't include derived data in your state or else you'll most likely experience painful bugs. I hope that you learned something and won't make the same mistakes I did, good luck!