React State Management Libraries and How to Choose

By Dave Ceddia

The idea of state is one of the trickier things to nail down when you’re starting with React, and as your app grows, so do your state management needs.

In this post I’ll give you the Grand Tour of state management options in React and help you decide which one to use in your project.

What is State?

Just so we’re on the same page, let’s talk about state for a second.

Every interactive app involves responding to events, like when the user clicks a button, and a sidebar closes. Or someone sends a message, and it appears in a chat window.

As these events happen, and the app is updated to reflect them, we say the state of the app has changed. The app looks different than it did before, or it’s in a new mode behind the scenes.

Things like, “whether the sidebar is open or closed” and “the messages in the chat box” are pieces of state. In programming terms, you’d probably have an isSidebarOpen variable somewhere in the app set to true, and a chatMessages array with the messages you’ve received.

At any given moment, broadly speaking, the “state of your app” is determined by all of that data. All those individual variables, whether they’re stored in local component state or some third-party state management store – that’s your app’s state.

This is the high-level concept of “app state”. We aren’t talking about React-specific stuff like useState or Context or Redux or anything yet.

What is State Management?

All of those variables that decide what state your app is in have to be stored somewhere. So state management is a broad term that combines how you store the state and how you change it.

React and its ecosystem offer lots of different ways to store and manage that state. And when I say lots I mean LOTS.

Storing the Data

For storage, you can…

  • keep those variables in local component state – whether that’s with hooks (useState or useReducer) or in classes (this.state and this.setState)
  • keep the data in a store, using a third-party library like Redux, MobX, Recoil, or Zustand
  • you can even keep them on the window object globally

React doesn’t care an ounce where you put the data, but…

Updating the Data and Re-rendering

To make your app interactive, you need a way for React to know that something changed, and that it should re-render some (or all) components on the page.

Because React, despite its name, is not “reactive” in the way some other frameworks are.

Some frameworks “watch” for things, and update accordingly. Angular, Svelte, and Vue do this, among others.

React doesn’t, though. It does not “watch for changes” and magically re-render. You (or something) needs to tell it to do that.

  • with useState, useReducer, or this.setState (classes), React will re-render when you call one of the setter functions
  • if you keep the data in Redux, MobX, Recoil, or some other store, then that store will tell React when something has changed, and trigger the re-render for you
  • if you opt to keep the data globally on window, you need to tell React to update after you change that data.

Oh, and to be totally clear, I do not recommend keeping your state globally on window, for all the usual reasons that global data is to be avoided. Messy code, hard to reason about, etc etc. I only mention it to say that it’s possible, to make the point that React truly couldn’t care less where its data comes from :)

When is useState not enough?

The useState hook is perfect for small amounts of llocal component state. Each useState call can hold a single value, and while you can make that one value an object that contains a bunch of other values, it’s a better idea to split them up.

Once you get past 3-5 useState calls in a single component, things are probably going to get hard to keep track of. Especially if those bits of state depend on each other. With complex interdependencies, a proper state machine could be a better way to go.

Next up, useReducer

The next step “up” from useState is useReducer. The reducer function gives you one centralized place to intercept “actions” and update the state accordingly. A useReducer call, like useState, can only hold one value, but with a reducer it’s much more common for that single value to be an object containing multiple values. The useReducer hook makes it easier to manage that object.

Avoiding Prop Drilling with Context

Beyond useState and useReducer, the next pain point you’re likely to feel is prop drilling. This is when you have a component that holds some state, and then a child component 5 levels down needs access to it, and you have to drill that prop down through each level manually.

The easiest solution here is the Context API. It’s built into React.

// Step 1: create a context. do this outside of any components,
// at the top level of a file, and export it.
export const MyDataContext = React.createContext();

// Step 2: In the component that holds the data, import that
// context and use the Provider to pass the data down
function TheComponentWithState() {
  const [state, setState] = useState('whatever');
  return (
    <MyDataContext.Provider value={state}>
      component's content goes here
      <ComponentThatNeedsData/>
    </MyDataContext.Provider>
  )
}

// Step 3: Anywhere in the subtree under the Provider, pull out
// the `value` you passed in by using useContext
function ComponentThatNeedsData() {
  const data = useContext(MyDataContext);
  // use it
}

Despite its simplicity, Context has one important downside though, and that’s performance, unless you’re very careful about how you use it.

The reason is that every component that calls useContext will re-render when the Provider’s value prop changes. Seems fine so far, right? Components re-render when data changes? Sounds great!

But now envision what would happen if that value was an object containing 50 different bits of state that were used all over the app. And they change frequently, and independently. Every time one of those values changes, every component that uses any of them would re-render.

To avoid that pitfall, store small chunks of related data in each Context, and split up data across multiple Contexts (you can have as many as you want). Or, look into using a third-party library.

The other performance gotcha to avoid is passing a brand new object into the value of the Provider every time. It looks innocuous and it’s easy to miss. Here’s an example:


function TheComponentWithState() {
  const [state, setState] = useState('whatever');
  return (
    <MyDataContext.Provider value={{
      state,
      setState
    }}>
      component's content goes here
      <ComponentThatNeedsData/>
    </MyDataContext.Provider>
  )
}

Here we’re passing an object containing the state and its setter, setState. Those two values are fine. setState will never change, and state only changes when you tell it to. The problem is the object wrapped around them, which will be created anew every time TheComponentWithState is rendered.

You might notice that the stuff we’re talking about here isn’t really about state management as much as it just passing variables around. This is Context’s main purpose. The state itself is kept elsewhere, and Context just passes it around. I recommend reading this post on how Context differs from Redux for more detail.

Also, check out the linked references below for more on how to fix the “fresh object” problem with useCallback.

Learn More

Third-Party State Management Libraries

Let’s go over the most widely-used important state management tools to know about. I’ve provided links to learn more about each one.

Redux

Redux has been around the longest of all of the libraries mentioned here. It follows a functional (as in functional programming) style, with a heavy reliance on immutability.

You’ll create a single global store to hold all of the app’s state. A reducer function will receive actions that you dispatch from your components, and respond by returning a new copy of state.

Because changes only occur through actions, it’s possible to save and replay those actions and arrive at the same state. You can also take advantage of this to debug errors in production, and services like LogRocket exist to make this easy by recording actions on the server.

Benefits

  • Battle tested since 2015
  • The official Redux Toolkit library cuts down on the boilerplate code
  • Great devtools make debugging simple
  • Time travel debugging
  • Small bundle size (redux + react-redux is around 3kb)
  • Functional style means very little is hidden behind the scenes
  • Has its own ecosystem of libraries for doing things like syncing to localStorage, managing API requests, and lots more

Drawbacks

  • The mental model will take some time to understand, especially if you’re not familiar with functional programming
  • Heavy reliance on immutability can make it cumbersome to write reducers (this is mitigated by adding the Immer library, or using Redux Toolkit which incldues Immer)
  • Requires you to be explicit about everything (this could be a pro or con, depending on what you prefer)

Learn More

MobX

MobX is probably the most popular alternative to Redux outside of the built-in Context API. Where Redux is all about being explicit and functional, MobX takes the opposite approach.

MobX is based on the observer/observable pattern. You’ll create an observable data model, mark your components as “observers” of that data, and MobX will automatically track which data they access and re-render them when it changes.

It leaves you free to define the data model however you see fit, and gives you tools to watch that model for changes and react to those changes.

MobX uses ES6 Proxies behind the scenes to detect changes, so updating observable data is as easy as using the plain old = assignment operator.

Benefits

  • Manages state in a truly “reactive” way, so that when you modify a value, any components that use that value will automatically re-render
  • No actions or reducers to wire up, just modify your state and the app will reflect it.
  • Magical reactivity means less code to write.
  • You can write regular mutable code. No special setter functions or immutability required.

Drawbacks

  • Not as widely used as Redux, so there’s less community support (tutorials, etc.), but much-loved among its users
  • Magical reactivity means less explicit code. (this could be a pro or a con, depending on how you feel about auto-update “magic”)
  • Requirement for ES6 Proxies means no support for IE11 and below. (If supporting IE is a requirement for your app, older versions of MobX can work without proxies)

Learn More

MobX State Tree

MobX State Tree (or MST) is a layer on top of MobX that gives you a reactive state tree. You’ll create a typed model using MST’s type system. The model can have views (computed properties) and actions (setter functions). All modifications go through actions, so MST can keep track of what’s happening.

Here’s an example model:

const TodoStore = types
  .model('TodoStore', {
    loaded: types.boolean,
    todos: types.array(Todo),
    selectedTodo: types.reference(Todo),
  })
  .views((self) => {
    return {
      get completedTodos() {
        return self.todos.filter((t) => t.done);
      },
      findTodosByUser(user) {
        return self.todos.filter((t) => t.assignee === user);
      },
    };
  })
  .actions((self) => {
    return {
      addTodo(title) {
        self.todos.push({
          id: Math.random(),
          title,
        });
      },
    };
  });

The models are observable, which means that if a component is marked as a MobX observer, it will automatically re-render when the model changes. You can combine MST with MobX to write reactive components without much code.

A good use case for MST is to store domain model data. It can represent relationships between objects (e.g. TodoList has many Todos, TodoList belongs to a User) and enforce these relationships at runtime.

Changes are created as a stream of patches, and you can save & reload snapshots of the entire state tree or sections of it. A couple use cases: persisting state to localStorage between page reloads, or syncing state to the server.

Benefits

  • The type system guarantees your data will be in a consistent shape
  • Automatic tracking of dependences means MST can be smart about only re-rendering the components that need to
  • Changes are created as a stream of granular patches
  • Simple to take serializable JSON snapshots of the entire state or a portion of it

Drawbacks

  • You need to learn MST’s type system
  • The tradeoff of magic vs. explicitness
  • Some performance overhead to patches, snapshots, and actions. If you’re changing data very rapidly, MST might not be the best fit.

Learn More

Recoil

Recoil is the newest library in this list, and was created by Facebook. It lets you organize your data into a graph structure. It’s a bit similar to MobX State Tree, but without defining a typed model up front. Its API is like a combination of React’s useState and Context APIs, so it feels very similar to React.

To use it, you wrap your component tree in a RecoilRoot (similar to how you would with your own Context Provider). Then create “atoms” of state at the top level, each with a unique key.

const currentLanguage = atom({
  key: 'currentLanguage',
  default: 'en',
});

Components can then access this state with the useRecoilState hook, which works very similarly to useState:

function LanguageSelector() {
  const [language, setLanguage] = useRecoilState(currentLanguage);

  return (
    <div>Languauge is {language}</div>
    <button onClick={() => setLanguage('es')}>
      Switch to Español
    </button>
  )
}

There’s also the concept of “selectors” that let you create a view of an atom: think derived state like “the list of TODOs filtered down to just the completed ones”.

By keeping track of calls to useRecoilState, Recoil keeps track of which components use which atoms. This way it can re-render only the components that “subscribe” to a piece of data when that data changes, so the approach should scale well in terms of performance.

Benefits

  • Simple API that’s very similar to React
  • It’s used by Facebook in some of their internal tools
  • Designed for performance
  • Works with or without React Suspense (which is still experimental as of this writing)

Drawbacks

  • The library is only a few months old, so community resources and best practices aren’t as robust as other libraries yet.

Learn More

react-query

React-Query stands apart from the others on the list becasue it’s a data-fetching library more than a state management library.

I’m including it here because often, a good chunk of the state management in an app revolves around loading data, caching it, showing/clearing errors, clearing the cache at the right time (or hitting bugs when it’s not cleared), etc… and react-query solves all of this nicely.

Benefits

  • Retains data in a cache that every component can access
  • Can re-fetch automatically (stale-while-revalidate, Window Refocus, Polling/Realtime)
  • Support for fetching paginated data
  • Support for “load more” and infinite-scrolled data, including scroll position recovery
  • you can use any HTTP library (fetch, axios, etc.) or backend (REST, GraphQL)
  • supports React Suspense, but does not require it
  • Parallel + Dependent Queries
  • Mutations + reactive re-fetching (“after I update this item, re-fetch the whole list”)
  • Supports cancelling requests
  • Nice debugging with its own React Query Devtools
  • Small bundle size (6.5k minified + gzipped)

Drawbacks

  • Could be overkill if your requirements are simple

Learn More

XState

This last one is also not really a state management library in the same sense as the others on this list, but it’s very useful!

XState implements state machines and statecharts in JavaScript (and React, but it can be used with any framework). State machines are a “well known” idea (in the sense of academic literature) that have been around for decades, and they do a very good job of solving tricky stateful problems.

When it’s hard to reason through all the different combinations and states a system can take on, state machines are a great solution.

As an example, imagine a complex custom input like one of those fancy credit card number inputs from Stripe – the ones that know exactly when to insert spaces between numbers and where to put the cursor.

Now think: What should you do when the user hits the Right Arrow key? Well, it depends on where the cursor is. And it depends on what text is in the box (is the cursor near a space we need to skip over? no?). And maybe they were holding Shift and you need to adjust the selected region… There are a lot of variables in play. You can see how this would get complicated.

Managing this kind of thing by hand is tricky and error-prone, so with state machines you can lay out all the possible states the system can be in, and the transitions between them. XState will help you do that.

Benefits

  • Simple object-based API to represent states and their transitions
  • Can handle complex situations like parallel states
  • The XState Visualizer is really nice for debugging and stepping through a state machine
  • State machines can drastically simplify complex problems

Drawbacks

  • “Thinking in state machines” takes some getting used to
  • The state machine description objects can get pretty verbose (but then, imagine writing it by hand)

Learn More

“What About X?”

There are plenty more libraries I didn’t have space to cover here, like Zustand, easy-peasy, and others. Check those out though, they’re nice too :)

Tips on Learning State Management

Small examples are good for learning, but often make a library look like overkill. (“Who needs Redux for a TODO list?!” “Why did you use an entire state machine for a modal dialog?!”)

Large examples are good for seeing how to put a thing into practice, but are often overwhelming as an introduction. (“Wow, these state machine things looks WAAAY too complicated”)

Personally, when I’m brand new to a thing, I’ll start with the small “silly” examples first, even when my real goal is something bigger. I find it’s easy to get lost in the weeds with real-world examples.

Good luck on your own state management journey :)