Introduction
As React applications grow in size and complexity, managing shared global state is challenging. The general advice is to only reach for global state management solutions when needed.
This post will flesh out the core problems global state management libraries need to solve.
Understanding the underlying problems will help us assess the tradeoffs made in the “new wave” of state management approaches. For everything else, it’s often better to start local and scale up only as needed.
React itself does not provide strong guidelines for solving shared global application state. As such, the React ecosystem has collected numerous approaches and libraries to solve this problem over time.
This can make it confusing when assessing which library or pattern to adopt.
A common approach is to outsource these kinds of decisions, and use whatever is most popular. Which as we’ll see, was the case with the widespread adoption of Redux early on, with many applications not needing it.
Understanding the problem space state management libraries operate in allows us to better understand why so many different libraries take different approaches.
Each makes different tradeoffs against different problems, leading to numerous variations in API’s, patterns, and conceptual models on how to think about state in React applications.
We’ll take a look at modern approaches and patterns that can be found in libraries like Recoil, Jotai, Zustand, Valtio and how others like React tracked and React query and how fit into the ever evolving landscape.
By the end, we should be more equipped to accurately assess each libraries’ trade-offs when choosing one that makes sense for our application’s needs.
The problems global state management libraries need to solve
Ability to read stored state from anywhere in the component tree.
This is the most basic function of a state management library. It allows developers to persist state in memory, and avoid the issues prop drilling has at scale. Early on in the React ecosystem we often reached for Redux unnecessarily to solve this pain point.
In practice, there are two main approaches when it comes to actually storing the state.
The first is inside the React runtime. This often means leveraging API’s React provides like
useState
,useRef
oruseReducer
combined with React context to propagate a shared value around. The main challenge here is optimizing re-renders correctly.The second is outside of React’s knowledge, in module state. Module state allows for singleton-like state to be stored. Which is easier to optimize re-renders through subscriptions that opt-in to re-rendering when the state changes. However, because it’s a single value in memory, you can’t have different states for different subtrees.
Ability to update stored state.
A library should provide an intuitive API for both reading and writing data to the store.
An intuitive API is often one that fits ones existing mental models. So this can be somewhat subjective depending on who the consumer of the library is.
Often times clashes in mental models can cause friction in adoption, or increase a learning curve. A common clashing of mental models in React is mutable versus immutable state.
React’s model of UI as a function of state lends itself to concepts that rely on referential equality and immutable updates to detect when things change so it can re-render correctly. But Javascript is a mutable language.
So when using React, we have to keep things like reference equality in mind. This can be a source of confusion for Javascript developers not used to functional concepts and forms part of the learning curve when using React.
Redux follows this model and requires all state updates to be done in an immutable way. There are trade-offs with choices like this. In this case a common gripe is the amount of boilerplate you have to write to make updates for those used to mutable style updates.
That’s why libraries like Immer are popular that allow developers to write mutable style code (even if under-the-hood updates are immutable).
There are other libraries in the new wave of “post-redux” global state management solutions, such as Valtio that allow developers to use a mutable style API.
Provide mechanisms to optimize rendering.
The model of UI as a function of state is both incredibly simple and productive.
However, the process of reconciliation when that state changes is expensive at scale. And often leads to poor runtime performance for large apps.
With this model, a global state management library needs to both detect when to re-render when its state gets updated, and only re-render what is necessary.
Optimizing this process is one of the biggest challenges a state management library needs to solve.
There are two main approaches often taken. The first is allowing consumers to manually optimize this process.
An example of a manual optimization would be subscribing to a piece of stored state through a selector function. Components that read state through a selector will only re-render when that specific piece of state updates.
The second is handling this automatically for consumers so they don’t have to think about manual optimizations.
Valtio is another example library that use
Proxy
’s under the hood to automatically track when things get updated and automatically manage when a component should re-render.Provide mechanisms to optimize memory usage.
For very large frontend React applications, not managing memory properly can silently lead to issues at scale. Especially if you have customers that access these large applications from lower spec devices.
Hooking into the React lifecycle to store state means it’s easier to take advantage of automatic garbage collection when the component unmounts.
For libraries like Redux that promote the pattern of a single global store, you will need to manage this yourself. As it’ll continue to hold a reference to your data so that it won’t automatically get garbage collected.
Similarly, using a state management library that stores state outside the React runtime in module state means it’s not tied to any specific components and may need to be managed manually.
More problems to solve: In addition to the foundational problems above, there are some other common problems to consider when integrating with React:
Compatibility with concurrent mode. Concurrent mode allows React to “pause” and switch priorities within a render pass. Previously this process was completely synchronous.
Introducing concurrency to anything usually introduces edge cases. For state management libraries, there is the potential for two components to read different values from an external store, if the value read is changed during that render pass.
This is known as “tearing”. This problem led to the React team creating the useSyncExternalStore hook for library creators to solve this problem.
Serialization of data. It can be useful to have fully serializable state so you can save and restore application state from storage somewhere. Some libraries handle this for you, while others may require additional effort on the consumer’s side to enable this.
The context loss problem. This is a problem for applications that mix multiple react-renderers together. For example, you may have an application that utilizes both
react-dom
and a library likereact-three-fiber
. Where React can’t reconcile the two separate contexts.The stale props problem. Hooks solved a lot of issues with traditional class components. The trade-off for this was a new set of problems with embracing closures.
One common issue is that data inside a closure is no longer “fresh” in the current render cycle. This leads to the data rendered to the screen not being the latest value. This can be problematic when using selector functions that rely on props to calculate the state.
The zombie child problem. This refers to an old issue with Redux where child components that mount themselves first and connect to the store before the parent can cause inconsistencies if that state is updated before the parent component mounts.
A brief history of the state management ecosystem
As we’ve seen, global state management libraries need to consider many problems and edge cases.
To better understand the modern approaches to React state management, we can take a trip down memory lane to see how past pain points have led what we call best practices today.
Often times these best practices are discovered through trial and error, and from finding that certain solutions don’t end up scaling well.
From the beginning, React’s original tagline when it was first released was the “view” in Model View Controller. It came without opinions on how to structure or manage state.
This meant developers were sort of on their own when it came to dealing with the most complicated part of developing frontend applications.
Internally at Facebook, a pattern was used called “Flux”, that lent itself to uni-directional data flow and predictable updates that aligned with React’s model of “always re-render” the world.
This pattern fitted React’s mental model nicely, and caught on early in the React ecosystem.
The original rise of Redux
Redux was one of the first implementations of the Flux pattern that got widespread adoption.
It promoted the use of a single store, partly inspired by the Elm architecture, as opposed to many stores that were common with other Flux implementations.
You wouldn’t get fired for choosing Redux as your state management library of choice when spinning up a new project. It also had cool demoable features like ease of implementing undo / redo functionality and time travel debugging.
The overall model was, and still is, simple and elegant. Especially compared to the previous generation of MVC style frameworks like Backbone (at scale) that had preceded the React model.
While Redux is still a great state management library with use cases for specific apps. Over time there were a few common gripes with Redux that surfaced that led it to fall out of favor as we learned more as a community:
Issues in smaller apps
For a lot of applications early on it solved the first problem global state management libraries need to solve. Which is accessing stored state from anywhere in the component tree.
This is important to avoid the pains of prop-drilling data (and callback functions to update that data) down multiple levels.
It was often overkill for simple applications that fetched a few endpoints and had little interactivity.
Issues in larger apps
Over time our smaller applications grew into larger ones. And we discovered that there are many different types of state in a frontend application in practice. Each with their own set of sub-problems.
We can count local UI state, remote server cache state, URL state, global shared state, and probably more distinct types of state.
For example, with local UI state, prop drilling both data and callback functions to update that data often becomes a problem relatively quickly as things grow. To solve this, using component composition patterns in combination with lifting state up can get you pretty far.
For remote server cache state, there are common problems like request de-duplication, retries, polling, handling mutations, and the list goes on.
As applications grow, Redux tends to want to suck up all the state regardless of its type, as it promotes a single store.
This commonly leads to storing all the things in a big monolithic store. Which often times exacerbated the second problem of optimizing run-time performance.
Because Redux handles the global shared state generically, many of these sub-problems needed to be repeatedly solved (or often just left unattended).
This led to big monolithic stores holding everything between UI and remote entity state being managed in a single place.
This becomes very difficult to manage as things grow, especially on teams where frontend developers need to ship fast. Where working on decoupled independent complex components becomes necessary.
The de-emphasis of Redux
As we encountered more of these pain points, defaulting to Redux when spinning up a new project became discouraged over time.
In reality, many web applications are CRUD (create, read, update and delete) style applications that mainly need to synchronize the frontend with remote state data.
In other words, the main problems worth spending time on is the set of remote server cache problems. These problems include how to fetch, cache, and synchronize with the server state.
Including many other problems like handling race conditions, invalidating and refetching of stale data, de-duplicating requests, retries, refetching on component re-focus, and ease in mutating remote data compared to the boilerplate usually associated with Redux.
The boilerplate for this use case often felt unnecessary and overly complex. Especially when combined with middleware libraries like redux-saga
and redux-observable
.
This toolchain was overkill for many of these types of applications. Both in terms of the overhead sent down to the client for fetching and mutations. But also in the complexity of the model being used for relatively simple operations.
It’s also worth noting here the popularity of Mob X - whose reactive model providing some inspiration for the newer approaches we’ll see in a bit.
The pendulum swing to simpler approaches
Along came React hooks and the new context API. For a time the pendulum swang back from heavy abstractions like Redux to utilizing native React context with the new hooks APIs. This often involved simple useContext
combined with useState
or useReducer
.
This is a fine approach for simple applications. And a lot of smaller applications can get away with this. However, as things grow, this leads to two problems:
Re-inventing Redux. And oftentimes falling into the many problems we fleshed out above. And either not solving them, or solving them poorly compared to a library dedicated to solving those specific edge cases. Leading many feeling the need to the promote the idea that React context has nothing to do with state management.
Optimizing runtime performance. The other core problem is optimizing re-renders. Which can be difficult to get right as things scale when using native context.
It’s worth noting modern user-land libraries such as
useContextSelector
designed to help with this problem. With the React team starting to look at addressing this pain point automatically in the future as part of React.
The rise of purpose-built libraries to solve the remote state management problem
For most web applications that are CRUD-style applications, local state combined with a dedicated remote state management library can get you very far.
Some example libraries in this trend include React query, SWR, Apollo and Relay. Also in a “reformed” Redux with Redux Toolkit and RTK Query.
These are purpose-built to solve the problems in the remote data problem space that oftentimes were too cumbersome to implement solely using Redux.
While these libraries are great abstractions for single page apps. They still require a hefty overhead in terms of Javascript needed over the wire. Required for fetching and data mutation. And as a community of web builders the real cost of Javascript is becoming more fore-front of mind.
It’s worth noting newer meta-frameworks like Remix address this. By providing abstractions for server-first data loading and declarative mutations that don’t require downloading a dedicated library. Extending the “UI as a function of state” concept beyond the just the client to include the backend remote state data.
The new wave of global state management libraries and patterns
For large applications, there is often no avoiding the need to have a shared global state that is distinct from remote server state.
The rise of bottom up patterns
We can see previous state management solutions like Redux as somewhat “top down” in their approach. That over time tends to want to suck up all the state at the top of the component tree. In this model state lives up high in the tree, and components below pull down the state they need through selectors.
In Building future facing frontend architectures we saw the usefulness of the bottom-up view to constructing components with composition patterns.
React hooks both afford and promote the same principle of composable pieces put together to form a larger whole.
With hooks we can mark a shift from monolithic state management approaches with a giant global store. Towards a bottom-up “micro” state management with an emphasis of smaller state slices consumed via hooks.
Popular libraries like Recoil and Jotai exemplify this bottom-up approach with their concepts of “atomic” state.
An atom is a minimal, but complete unit of state. They are small pieces of state that can connect together to form new derived states. That ends up forming a graph.
This model allows you to build up state incrementally bottom up. And optimizes re-renders by only invalidating atoms in the graph that have been updated.
This is in contrast to having one large monolithic ball of state that you subscribe to and try to avoid unnecessary re-renders.
How modern libraries address the core problems of state management
Below is a simplified summary of the different approaches each “new wave” library takes to solve each of the core problems of state management. These are the same problems we defined at the start of the article.
Ability to read stored state from anywhere within a subtree
Library | Description | Simplified API example |
---|---|---|
React-Redux | React lifecycle | useSelector(state => state.foo) |
Recoil | React lifecycle |
|
Jotai | React lifecycle |
|
Valtio | Module state |
|
Ability to write and update stored state
Library | Update API |
---|---|
React-Redux | Immutable |
Recoil | Immutable |
Jotai | Immutable |
Zustand | Immutable |
Valtio | Mutable style |
Runtime performance re-render optimizations
Manual optimizations - often mean the creation of selector functions that subscribe to a specific piece of state.
The advantage here is that consumers can have fine-grained control of how to subscribe and optimize how components that subscribe to that state will re-render.
A disadvantage is that this is a manual process that can be error-prone and, one might argue, requires an unnecessary overhead that shouldn’t be part of the API.
Automatic optimizations - is where the library optimizes this process of only re-rendering what is necessary, automatically, for you as a consumer.
The advantage here is the ease of use and the ability for consumers to focus on developing features without needing to worry about manual optimizations. A disadvantage of this is that as a consumer, the optimization process is a black box, and some parts may feel too magic without escape hatches to manually optimize.
Library | Description |
---|---|
React-Redux | Manual via selectors |
Recoil | Semi-manual through subscriptions to atoms |
Jotai | Semi-manual through subscriptions to atoms |
Zustand | Manual via selectors |
Valtio | Automatic via Proxy snapshots |
Memory optimizations
Memory optimizations tend to only be issues on very large applications. A big part of this will depend on whether or not the library stores state at the module level or within the React runtime. It also depends how you structure the store.
The benefit of smaller independent stores compared to large monolithic ones is they can be garbage collected automatically when all subscribing components unmount. Whereas large monolithic stores are more prone to memory leaks without proper memory management.
Library | Description |
---|---|
Redux | Needs to be managed manually |
Recoil | Automatic - as of v0.3.0 |
Jotai | Automatic - atoms are stored as keys in a WeakMap under the hood |
Zustand | Semi-automatic - API’s are available to aid in manually unsubscribing components |
Valtio | Semi-automatic - Garbage collected when subscribing components unmount |
Concluding thoughts
There’s no right answer as to what is the best global state management library. A lot will depend on the needs of your specific application and who is building it.
Understanding the underlying unchanging problems state management libraries need to solve can help us assess both the libraries of today and the ones that will be developed in the future.
Going into depth on specific implementations is outside the scope of this article. If you’re interested in digging deeper, Daishi Kato’s React state management book is a good resource that delves deeper into specific side-by-side comparisons of some of the newer libraries and approaches mentioned in this post.