State Management in React Applications
In general, this guide applies to managing state on the client (browser). Since the entire premise of SSR is SERVER-side, this guide doesn't really apply to managing any state on the server. To summarize:
- Even apps that use SSR might also need to manage state on the client
- Simple CRUD-like applications often means you can shift/move some of your client state to the server
Before explaining this approach to State Management, let's get on the same page regarding some phrases frequently used.
Outlined here are the various categories data your application might need to manage. Understanding the disctinction between each is crucial because some state-management tools are better suited for different categories of state data than others.
If you're interested in a more detailed definition of each state category below, you might take the time to read The 5 Types of React Application State. Think of these categories as out of your control; this is simply the nature of the web and how the industry has decided to categorize information.
Categorically speaking, ALL state we manage in applications can be written as either "local" or "global" state - a piece of state becomes global based on if its exposed and consumed by the rest of the application (in other components) without passing the data as props. None of the types of state we covered above (1-5) are inherently local or global. For some types, it may seem obvious - a trivial example of this is the UI state - of which you should use React hooks (like
useReducer) to manage, almost always. Technically you could configure some UI state to be in a global store such as Redux, but in practice, there's rarely a need for that type of state data to be accessible globally, and configuring it that way will eventually cause performance issues in your application.
To restate our point: you, the developer, must decide what state data should be local vs. global. The trait that defines if a piece of state is local or global is whether the state data is accessed in multiple components without being passed as props.
- If other components (like children) receive state via props, that is local data
- If other components (like siblings or distant relatives) receive the same state data via hooks/functions, that is global data that's been injected/selected into your component
Now that you know all the types of state, the remainder of this guide will focus heavily on UI State, Communication State, and Application State (1, 2, and 3 above).
Those specific 3 types are the focus because the other types (4. Session State and 5. Location state) can easily be solved for us by tapping into the vast React ecosystem without much consideration. Or, put differently, most apps require the same functionality when it comes to routing and token storage, so there's not much to solve.
It's infrequent that you should write a custom location state manager when plenty of better options already exist for managing navigation history (like react-router-dom or reactnavigation for React-Native apps).
And for session state - there is very little complexity to storing tokens (but some overlap with other session data you can read in the Overlaps section below), so here's a summary:
There are three primary approaches to managing application state:
- Internal React APIs (
- "Smart" Data-fetching libraries (tanstack-query, rtk-query, swr, etc.)
- Global stores (Redux, Zustand, Jotai, etc.)
Managing state is never as simple as "always use
X library for
Y requirement". Below are some quick examples of how each approach can manage state in both localish/globalish ways:
- React APIs: can handle local state AND global state
useStatefor a local toggle in a component,
useContextfor global dependency injection into components throughout your React tree
- Data-fetching libraries: a blend of local & global state
- Ex: using an API endpoint response in only one component (localish, because that data is only accessed in one container) or re-using the cached response data from that same API endpoint in another component (globalish now, because the data is accessed via a selector, the url, instead of as a prop)
- Global stores: for handling exclusively global state
- Ex: The most common use for global stores are when many components need to access the same information. Even if a global store is used to manage some UI state (which you shouldn't do), that state data would be configured so that its globally accessible in all other components.
Note there is some overlap above - each can solve local/global state to a degree, some just work better for each than others. Again, it is your choice to make your state data hidden in a component (local) or exposed and accessed in other parts of your React tree (global).
However, developers often do reach for global state manager libraries too soon. There is a balance that we need to get right, otherwise we'll end up with bloated applications. Before reaching for a global state, lets cover the ways we can maximize local state in our React app (both for performance and maintainability).
Now that you understand the differences between local and global state - you need to understand how far you can take local state before introducing a global store in your application.
- Choosing the State Structure - Advice for how to structure the specific data you will be managing
- Extracting State into a Reducer
- Lifting State - Lifting state to a parent can sometimes alleviate the need for global stores
- If you're still prop drilling a lot (after trying the above approaches to lifting state) or feel you're headed in that direction and want to avoid it
- If you have some type of state/store that lives outside of your React application, but you need to consume it in your React app
If you've come this far and now you know you need a global state - the next question is - what are your options? Here are the two popular approaches you can take:
- Data Fetching & Server Cache (tanstack-query, rtk-query, swr)
- Global Stores (Redux, Recoil, etc.)
There are times where you might not need an entire state manager. If your global state is relatively small/simple, you might be fine with one of the following instead:
- Local/Session Storage
Why Not Context?
The React docs explain how to use React Context for more global-like state management. While this can be a suitable solution for simple applications, we find that using Context correctly is typically hard to get right as a global state manager, especially if you have lots of data and its frequently being accessed by various trees/nodes in your application.
Our upcoming advanced State Management Guide will explain how to use Context correctly, when it's a good choice, and why we think there are better options. The takeaway here is that an efficient (optimized for re-renders) solution for global state management is not an easy task with Context.
Before exploring global stores - you should be aware of a common situation in a lot of apps where people *think they need a global store - but don't actually. Picture this React app: a dashboard behind authentication with only several routes (pages). One of the routes simply fetches a dataset from an API and renders a bar graph based on that dataset. Then, a second route also fetches some data from an API, but has a
<form /> on the same page that allows the user to update or mutate that data. After the user submits the form, the application should re-fetch the data from the API to have the new data instead of the old stale data the user previously saw.
If you can step back and observe this at a high level… often, this basic CRUD behavior (fetching data, mutating data, then re-fetching data because we know it's stale now) is the majority of an application's state.
The way these data-fetching libraries work to simplify your state manager - is they cache the response of each API endpoint that's getting requests from your app and let you easily re-access the response. The basic idea is when you have multiple components consuming the same endpoint data - the API only gets hit once instead of 3 times (once when each component is rendered) and that response is turned into a global state that gets shared.
The library only needs you to pass the URL string and some options that dictate when the cache should be cleared + refetched. The cache provides you a global store to consume data from in your components, albeit, a pretty simple one that expects all of your data is coming from an API.
TLDR: If most of your app state simply reflects your server state and sharing the server API responses between components, "smart" Data Fetching is probably all you need. Here are some additional things that data-fetching libraries also tend to solve:
- Polling on a time interval
- Revalidation on window focus
- Revalidation on network recovery
- Local mutation (Optimistic UI)
- Smart error retrying
- Pagination and scroll position recovery
When Data-Fetching Isn't Enough
When you're unsure if smart data-fetching will be enough or if you'll need a "store" of some kind, ask yourself the following questions:
- Will the data in your React app go for extended periods without the need for re-fetching from the API?
- Will the state/data in your React app likely get out-of-sync with the server state, intentional or not, during regular application usage?
- Will your application state be updating data frequently enough that its unreasonable for EVERY change to be persisted to the server immediately when they occur?
- Will your server state update frequently enough that your React app might not know when the cache is stale and data should be re-fetched?
- Is it mission-critical that your application shows the latest (uncached) reflection of server state at all times?
If you answered "Yes" to any of the questions above, you should consider a more centralized global state as a potential solution for more complex requirements.
The different recommended approaches for global stores are:
- Singleton - the singleton (AKA "module") architectural approach typically means a single instance of the state (called a "store") that is shared across the entire application. Typically this means one large object that holds ALL state values in your application.
- Atomic - the atomic pattern (often associated with Recoil & Jotai) all centers around the idea of an "atom" - an isolated piece of state. Atoms are consumed & mutated from anywhere in your application. This pattern differs from the singleton approach above by splitting your state into smaller related groups of data that allow more granular interactions with global state data. Atoms can also be grouped together to derive state.
- Proxy - this architecture pattern introduces a proxy object for you to interact with, while that proxy communicates with the source of truth (the store). This approach is intended to make modifying the application state simpler by allowing any assignment/mutation and the proxy forwarding those updates back to the central store (this is the antithesis or opposite of "immutability", really)
Next, you can read more about the tradeoffs and benefits each architecture offers 👇
The links below take you to bundlejs.com to demonstrate what the bundle size might result in (using latest versions).
|Pattern||Library||Links to Bundle Size||Popularity (weekly downloads)|
|Flux Module||redux-toolkit||bundlejs.com||2 million|
|Flux Module||zustand||bundlejs.com||1.5 million|
As we've just been through many state solutions - undoubtedly there's some "overlap" between what they're able to do. When there's overlap, it can be confusing as to which direction you should take. There are some recommendations below for questions you'll frequently run into while deciding the best ways to manage application state.
The Overlaps Between Each Approach to Global State
- Contexts / Stores share a very similar approach (avoiding prop-drilling to share data), Stores just tend to have more features out of the box and better options for re-render optimizations
- Stores / Data-Fetching Caches also share a similar approach (sharing the same data between components without props / prop-drilling), the data-fetching solutions just assume all/most client state is simply a reflection of the server state (retrievable via API endpoints)
- All 3 solutions (Contexts, Stores, Data-Fetching) consume data from "somewhere else" and use it for things like rendering, mutations/actions, etc.
Specific common overlaps between "App" State and "Session" State
- Keep things like tokens in localStorage, and the actual data those tokens receive in your contexts/stores. The reason is: the token rarely plays a role in the logic/views of your app. It's usually just included in API requests.
- The performant way to store tokens would be to configure your requests globally to use the same API token retrieved from localStorage (just once) instead of constantly retrieving the token from localStorage and then including it in your requests as an HTTP header in each component (this is massive duplication of code and easily avoidable)
- Secondly: should you put "user" info in a store or a context, like a UserContext/AuthContext or something?
- If the auth is handled by the same system/API that you're getting your app data from - just throw it all in the store. Arbitrarily mixing contexts with global stores isn't necessary and would be a confusing division of data
- If the auth is handled by a 3rd party system like Okta, Clerk, OAuth, etc. you could probably just wrap the user/profile info provided back by those parties in a context (since that data will rarely change) and then keep the rest of your business/app data in stores/caches
Thanks / References
Thanks for reading! If you have any suggestions on how developers can approach managing state in their React applications, let's talk (You will get credit for your contributions!).
Specific thankyou to editors of this guide: