Using React Context for state management is fine
While it seems to be diminishing, a far too common opinion about the React Context API is that it is not a good choice for managing state. The usual reasons stated are that contexts don't scale and cause performance issues.
While these are certainly issues you can run into when using contexts, they are not necessarily inherent traits of contexts themselves. And having used contexts for very complex state management in large applications, I can attest that contexts can scale, when used appropriately.
So, when can using contexts cause issues? When rapid changes cause large parts of the component hierarchy to rerender and rerendering is costly. And when is rerendering costly? When it involves rerunning a lot of non-trivial code.
Note that there can be other perfectly valid reasons for using alternative state management libraries aside from performance. And that's fine! Use what makes you happy.
Just don't disregard the context alternative due to misconceptions about its performance characteristics.
Stable references
In my post regarding useCallback
and useMemo
, I mentioned the importance of stable references and how I think it's better to default to always using useCallback
and useMemo
for functions/objects/arrays instead of the inverse. Yes, it adds a tiny bit of overhead if it wasn't strictly necessary. And yes, it is a bit of extra ceremony to wrap those functions/calculations everywhere. But honestly, how many systems, frameworks or libraries can claim they have none of that?
With time, these things blend a bit into the background as they grow into habits. And habits take less mental capacity than having to consider each and every case. So I would still recommend using them by default.
The lack of stability, possibly caused by the lack of using useCallback
and useMemo
, means a state update triggers reevaluation of a lot more code than it otherwise would. And that means using the React Context API for state management can become very performance intensive as an application grows.
Keeping rerenders as cheap as possible can go a very long way before you need to care about reducing the number of rerenders.
Considering the default recommended "alternative" to state in contexts was Redux for a long time, ceremony around hooks cannot have been the primary motivation to avoid it. Perhaps the readjustment it takes to "think in hooks" is a more likely reason.
Luckily, it seems Redux has been replaced with some more sane alternatives in most recommendations. I'm still using Redux as a comparison for the rest of this article, but all state management alternatives that rely on global keys, symbols, or variables (sorry, atoms) have some issues that contexts can avoid.
All the state
A suspicion I have when I read some strong opinions about how terrible state in context is, is that someone converted their set of Redux reducers into a single behemoth of a ApplicationStateContext
and tried to manage it as one big pile of all the things.
Again, contexts are costly, so try to keep the number low, right?
I don't think so. The primary things that Redux and other state management libraries actually help with, are splitting the state and their management into logical pieces, as well as limiting the scope of components affected by state changes. Throwing out Redux doesn't mean we have to throw out these benefits as well.
So, if you have a userReducer
maintaining the logged in user state and related information, you can create a UserContext
that keeps that information. Then, only the components actually relying on user information will need to rerender when the user information changes.
And if you have a mapReducer
that contains the current state and content of a complex interactive map, and provide actions to control it from various parts of the application, then a MapContext
will probably make sense as well.
This example illustrates one of the shortcomings of Redux and other state management libraries that rely on state that is essentially global to the entire application (like all the atomic variants I've seen so far). What if your requirements suddenly change, and you need to show two of these interactive maps at the same time? Now your mapReducer
needs to be instantiated twice. But that's not trivial, as state keys and action keys (or your atoms) are all global.
Cue complex namespacing schemes, or higher order reducers, and selector props passed around everywhere.
A React Context, on the other hand, can live wherever you need it to in the component hierarchy, so two instances can live perfectly fine side by side. Actually, they can even be nested. useContext
finds the first one upwards in the component tree, so if you have a global map that is controlled from most of the application, you can still nest a smaller map inside it where necessary.
I use maps as an example, as this case has arisen in practice a few times in the larger and more complicated applications I work with in my day job, where we develop complex GIS applications.
Actually, Redux providers rely on the React Context API as well, and can therefore also be nested if needed. But since it gathers all state in a single provider, you cannot use two of them from the same location in the hierarchy.
Not all the state
A common pitfall with state management in React in general is to think that all state is the same. I've seen it happen more readily when using something like Redux in a project, as it tends to be viewed as the "one true way" to keep state. The simple useState
is suddenly a second-class citizen.
But state should be scoped. And only truly global state needs to live in the top level of you application hierarchy. So your UserContext
should probably render its <UserContext.Provider>
very near the top, but that MapContext
can be scoped to the parts of the application that actually interacts with the map.
And this is the beauty of React Context. If you split it into functionally scoped pieces, you can use and reuse those contexts at the level it makes sense. The state they provide can be as global or local as you want them to be.
Especially not that state
Even more importantly, not all state needs to be lifted up at all.
Form state is a good example. Usually, you don't want that to live longer than necessary, or you'll end up with a cleanup job that quickly enough evolves into a buggy mess. And if you lift the state high enough, every keypress triggers a reevaluation of your entire app. Yikes.
So perhaps scope that form state manager to the same scope as your <form>
itself. Or even better, if your requirements are fairly straightforward, let the state live in the input elements until they are actually submitted. The browser have managed this fairly well for a few decades, and obtaining it in a friendly interface has become as easy as a new FormData(e.target)
in the onSubmit
callback.
Other examples that really is local to components include menu, popup and tooltip visibility and expanded/collapsed states. And with all the options in modern HTML elements and CSS selectors available, a lot of these don't need to live in your code at all.
But it's still slow!
Of course, in large applications with tons of data and complex rendering of that data, things can slow down anyway. But before you rewrite your app to use Redux, MobX, Signals, Recoil, Zustand, Zedux or any of the alternatives; please, please, please, profile your application to see what is actually causing the slowdown.
And check if those hook dependencies are stable!