It is relatively easy for an app to outgrow reliance on props for sharing state between components. Before the days of the Context API and hooks, centralized state management solutions (mainly Redux) were the go-to tools to efficiently read and update state across the app.
The question now is whether such solutions remain necessary to scale an application. This post attempts to justify that the answer is "No".
This is hopefully achievable by exploring the challenges that centralized state management introduces, and the way to circumvent the performance cost of the - much more flexible - Context API.
Centralizing all shared state, by definition, makes all shared state global. This can lead to leaner components as they become more focused on presentation than on the data itself. It also prevents prop-drilling which we can all agree is an absolute nightmare to track or - God forbid - refactor.
However, these benefits come with a tradeoff in terms of code readability. Even assuming good design i.e., keeping what should be local state local, we will end up with more global state than necessary. Generally speaking, the more global state we have, the higher the maintenance impact in two main aspects:
It is far easier to follow the code when the component's state is co-located with its rendering logic. When that's not possible, it remains easier to track the state of a closely related, small subtree of components that can be thought of as a module.
There is rarely, if ever, a reason for the entire application to be a single set of tightly coupled atomic parts; which means there is rarely a justifying reason to have state accessible by all atomic parts.
Tracking state, and therefore clearly understanding the why behind your app's behavior, is greatly improved by respecting ownership. That is, when the logical owner of a state is the one initializing it and controlling which children at the lower levels of the app's component tree can access or mutate it.
Global state inherently adds complexity because it can be altered from anywhere in the app which makes the consuming component less predictable, and harder to test.
Dan Abramov, the co-creator of Redux, acknowledges that this is the main issue of using the library.
First of all, I think it's interesting - to say the least - that the co-creator of Redux doesn't like it very much.
More to the point, while Dan's response acknowledges that needlessly globalizing state can be detrimental, I think the original tweet was on to something. It is true that where the state is managed is easy to track in Redux. However, can the same be said about knowing which components use the state?
Given a state, you know where the code lies by following the reducer chain. You also know what controls the state by following the actions. However, you don't readily know what the state affects—there is no easy way to know which
useSelector call is actually reading it.
One argument here could be that this is a matter of design. We could limit the use of the central store to state that is truly global.
But then it becomes questionable whether a full-featured library is actually needed to manage state that is strictly global. Except for maybe some edge cases, only a fraction of an app's total state needs to be global, and such state typically doesn't change often (think user info).
When the app involves some sort of server state (like most apps do) the central store usually ends up being used as a cache for the server data, even though that is not its purpose. Additionally, "caching" using the central store adds a great deal of complexity because server state involves more than the data; it involves the state of the request as well.
To manage server state with a centralized solution, there are generally two approaches:
Defer the fetching action to the state management solution (e.g. thunks in Redux) which would handle the loading and error states of the requests, then store the response's data in the global store.
Handle the request along with its loading and error states inside an effect, then store the response's data in the global store.
To state the obvious, the last step is common between both approaches. The problem with this step is that we now have two sources of truth: the server, and the store. This clearly, and unnecessarily, introduces the complexity of keeping them in sync.
To keep the server as the single source of truth, we begin with the URL.
To elaborate on a succinct statement of wisdom, a URL generally provides two key pieces of information:
- Where you are in the app
- Key data elements that define your state
Dynamic data in the form of path and query parameters can take you a long way in providing your components with the state they need. You don't need to store data across various stages of your app for it to work. What you need is the data's defining ID which you can grab from the URL and throw at the server. Naturally, this will have a performance impact on your app, but the important thing to note here is that it does not break functionality.
As for the performance impact, while a central store can be used to mitigate it, that's not a store's purpose. Dealing with that problem is where dedicated data-fetching and caching solutions like React Query and SWR come in.
With features like deduping, auto re-fetching, and tracking the request's various state elements; these libraries not only eliminate the need for a global store to manage server state, but also allow for true separation of concerns that is in line with React's philosophy by allowing developers to fetch data where it is used—separating concerns by separating entities.
Deduping and caching with timers like
cacheTime allow each component to handle its own server state without any performance impact. This plays a significant part in decoupling components—or at least decoupling subtrees of components.
On the other hand, handling server state in a centralized store leads to managing more state than necessary directly in the app, and inevitably leads to tight coupling of components. Both factors significantly increase the complexity of your app, and there is little added value left in using such an approach.
Despite the arguments made so far, there is still one area in which a state-management library clearly trumps a regular context: performance.
The main problem with the Context API is that when the
Provider re-renders due to some change in its state, every
Consumer re-renders regardless of whether it was affected by that state or not. This is fundamentally different from how Redux state consumers behave. With Redux, only the components consuming the altered state re-render.
At first glance, this might seem like a deal breaker. However, there are two important points to consider here:
In cases where the subtree that re-renders is small, or the provider's state is not expected to change frequently (e.g.
AuthProvider); the performance difference becomes irrelevant. Difference in performance is only a factor when it is significant (even mildly significant); not when you are micro-optimizing.
The key point to remember in the issue at hand is that the re-rendering chain begins when the state of the provider changes. What if we can have consumers subscribe to changes that are not part of React's state? In other words, what if we could achieve reactivity using the Context API without allowing the
Provider component to re-render?
What Redux and other solutions do isn't magic. It is relatively simple to achieve similar selective reactivity using the Context API as well. Jack Harrington had an excellent video about this. And, to make a point, I actually abstracted away the logic of creating a smarter context where the provider doesn't blindly re-render consumers. The library is less than 100 lines of actual code (including type definitions). As I said, it's relatively simple.
Ultimately, the aim is to strike a balance between two extremes:
- Mixing server and client state in one store and globalizing most of the app's state
- Prop drilling client state and having every other component make an API call even when it can easily grab a value from the parent
I think it is possible to reach some point in the middle by adhering to these principles:
- Default to props until you start prop-drilling
- Create a context when props are not enough
- The context provider should be at the lowest possible level in the component tree. This is usually also the logical owner of the state
- The server state's parameters should in most cases be URL parameters
- Use a data-fetching/caching library
- The same principle for deciding where to place a context provider applies for deciding where to fetch the data
- When the state is expected to change frequently, or is needed globally, use a smarter context (or use that everywhere)
Our focus should always be on building robust, maintainable applications, and our weapon of choice should be dictated by that. The heaviest sword is not always the right one, and while centralized state management solutions such as Redux were, and continue to be, powerful tools for building complex applications, they are not necessary for the majority of the cases.
The rise of React's Context API, hooks, and data-fetching libraries like React Query present developers with the ability to manage state in a more component-centric, efficient, and flexible manner. These tools facilitate the co-location of state and logic, improve code readability, and simplify state flow. Relying on centralized stores trades off these features and the benefit is only clear in a small subset of applications where state needs to be globalized.