DEV Community

Cover image for The new wave of React state management
rem
rem

Posted on • Originally published at frontendmastery.com on

The new wave of React state management

Introduction

As React applications grow in size and complexity, managing shared global state is a challenge. The general advice is to only reach for global state management solutions when you need it.

This post will flesh out the core problems global state management libraries need to solve.

Understanding the underlying problems will help us assess the trade-offs made 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 any strong guidelines for how to solve this for 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.

The common approach is to outsource this 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.

By understanding the problem space state management libraries operate in, it allows us to better understand why there are so many different libraries taking 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.

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 the trade-offs libraries make when we need to chose one that makes sense for our applications needs.

The problems global state management libraries need to solve

  1. 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 their 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 or useReducer 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. It's often 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.

  2. Ability to write to stored state. A library should to 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 changes so it can re-render correctly. But Javascript is a mutable language.

    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 be done that 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 is 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.

  3. 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 it's 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.

  4. Provide mechanisms to optimize memory usage. For very large frontend 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 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 lead 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 consumers 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 like react-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 that come with embracing closures.

    One common issue is data inside a closure no longer being "fresh" in the current render cycle. Leading to the data that is rendered out to the screen not being the latest value. This can be a problem 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 there's a lot of problems and edge cases global state management libraries need to take into account.

To better understand all the modern approaches to React state management. We can take a trip down memory lane to see the how the pain-points of the past have lead to lessons that we call "best practices" today.

Often times these best practices are discovered through trial and error and from find 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 was 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 that has real use cases for specific apps. Over time there were a few common gripes with Redux that surfaced that lead it to fall out of favour as we learnt more as a community:

  • Issues in smaller apps

    For a lot of applications early on it solved the first problem. Accessing stored state from anywhere in the tree to avoid the pains of prop-drilling both data and 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 as we discovered that in practice there are many different types of state in a frontend application. Each with their own set of sub-problems.

    We can count local UI state, remote server cache state, url state, and global shared state, and probably more distinct types of state.

    For example with local UI state, prop drilling both data and methods to update that data often becomes a probably 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 it's type, as it promotes a single store.

    This commonly lead 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, a lot of these sub problems needed to be repeatedly solved (or often times just left un-attended).

    This lead to big monolithic stores holding everything between UI and remote entity state being managed in a single place.

    This of course 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, over time defaulting to Redux when spinning up a new project became discouraged.

In reality a lot of 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 server state.

It also includes 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 was unnecessary and overly complex. Especially so when commonly combined with middleware libraries like redux-saga and redux-observable.

This toolchain was overkill for these types of applications. Both in terms of the overhead sent down to the client for fetching and mutations but in complexity of the model being used for relatively simple operations.

The pendulum swing to simpler approaches

Along came hooks and the new context API. For a time being the pendulum swang back from heavy abstractions like Redux to utilizing native 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 lead to two problems:

  1. Re-inventing Redux. And often times falling into the many problems we defined before. 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.

  2. 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 often times 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 downloaded 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 needing to have 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. 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.

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 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 const todos = atom({ key: 'todos', default: [] })
const todoList = useRecoilValue(todos)
Jotai React lifecycle const countAtom = atom(0)
const [count, setCount] = useAtom(countAtom)
Valtio Module state const state = proxy({ count: 0 })
const snap = useSnapshot(state)
state.count++

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 of course 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 without escape hatches to manually optimize some parts may feel a bit too magic.

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 to dig deeper I can recommend Daishi Kato's React state management book, which is a good resource to go deeper into specific side-by-side comparisons of some of the newer libraries and approaches mentioned in this post.

References

Top comments (0)