Which of the popular React state management libraries is the best? Let’s find out!
Table of Contents
-
What is a state management library?
-
The Cart application
-
Implementation with Redux and Redux-Toolkit
-
Implementation with Jotai
-
Implementation with Zustand
-
Summary
What is a state management library?
Frontend UI frameworks like React, are great when it comes to handling the UI efficiently. They are a primary way of creating web applications nowadays. However, they are usually not so great when it comes to scalable, shared state management.
The state describes the data that drives the UI.
I already discussed the nature of state in much detail in a previous article Demystifying React State Management: A Comprehensive Guide, so here I will only summarize the most important things:
-
In a web application, the state can be either local or shared.
-
Local state is usually well handled by the UI library itself.
-
The shared state spans through many components, and it can be challenging to keep it synchronized and write it in a scalable and maintainable way.
In another article, Build a React cart system with the Context API and useReducer hook, I explored in-depth the built-in React tools shared state management, using a typical online store scenario as an example.
The React tools that we used are the useReducer hook and the Context API. They are good tools for small applications, but in a bigger product that requires constant development and cooperation between multiple developers and teams, it can become hard to scale and maintain.
That’s where a state management library comes into the stage.
An external state management library has some sort of data store that holds the data and takes care of all the synchronization, persistence, and performance aspects. It exposes a usually simple API that allows the developer to get and set the pieces of the data, plus some options to better separate different domains.
There are quite a lot of different libraries to choose from. Finding “the right one” for your project can be overwhelming, so I did a small experiment, in which I tried to rewrite the cart functionality from the above-mentioned article to use some of the most popular state management libraries right now.
I present the results below, and I hope that they will give you some more insight into this topic.
The Cart application
The test application contains a simplified cart functionality, similar to what you probably saw multiple times in online stores. It is composed of two views:
-
Product list
-
Cart
A user can put items into the cart, using the Product list view. He can also edit their quantity, or remove them in the Cart view. The data representing the items in the cart is used in many places of the application, so it is contained in a shared state.
You can check out the full code in the project’s repository.
Implementation with Redux and Redux-Toolkit
Resources:
Redux is one of the oldest state management libraries in the React ecosystem. It is based on the reducer pattern.
As a quick reminder — the reducer pattern consists of having a function, called a reducer, that takes the current state and an action and returns the new state as a result. The action informs the reducer what part of the state it wants to change, and the reducer is responsible for applying that change.
The data flow is unidirectional, which makes it easy to track changes in the state. It allows to have multiple “stores” that contain pieces of the data, and their matching reducers, for better scalability and maintenance.
Redux implementation assumes the following:
-
There is one central store, which contains all of the data. This store can be created by merging partial stores together.
-
Redux can be extended with middleware — custom functions that handle different use cases and side effects. These middleware functions can be piped into the flow of the data, allowing for very powerful patterns.
-
Redux has a great browser extension — ReduxDevTools — that allows you to easily check the history of what happened in your store step-by-step right in the browser console.
-
Redux doesn’t handle asynchronous actions, which makes it hard to work with data that requires fetching and other async operations. There is special middleware that enables the handling of async actions, like redux-thunk. Unfortunately, it makes the implementation ugly and adds more boilerplate code and libraries that the user needs to depend on.
There are two main ways you can use Redux in a React application.
-
Classic way with just React Redux
-
New, better way with Redux Toolkit
The classic way: React Redux
The react-redux is the official binding between the original, framework-agnostic redux library, and React.
Using it is very similar to the useReducer hook, but unfortunately, it has the same downsides, namely:
-
Complicated setup and configuration
-
A lot of boilerplate code
-
The immutability rules around the reducer make things complex and make it easy to introduce bugs
It also requires additional libraries to handle async actions (like redux-thunk or redux-saga ). Those libraries have *weird *setups.
The new way — Redux Toolkit
The library @reduxjs/toolkit is a set of opinionated utility functions that aim to solve the complexity and maintainability issues around react-redux.
It achieves that by:
-
Simplifying the store configuration.
-
Reducing the boilerplate code.
-
Hiding the hard parts of immutability behind the API — the developer doesn’t need to bother that much anymore.
-
The store configuration contains redux-thunk by default, so no need to add it yourself.
The implementation of the cart functionality will be using Redux Toolkit as it’s the recommended way to use Redux in a modern React app.
Note: The store has to be persisted through user sessions in the localStorage, so in every implementation, we’ll add some logic to support that.
The store setup:
import { configureStore } from "@reduxjs/toolkit"
import { persistStore, persistReducer } from "redux-persist"
import storage from "redux-persist/lib/storage" // defaults to localStorage for web
import { cartReducer } from "./cartReducer"
const persistConfig = {
key: "root",
storage,
}
const persistedReducer = persistReducer(persistConfig, cartReducer)
export const store = configureStore({
reducer: {
cart: persistedReducer,
},
})
export const persistor = persistStore(store)
We can define our own typed hooks for the action dispatchers and state selectors that we will use for type safety instead of the default ones.
The store’s hooks:
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
import type { RootState, AppDispatch } from "./store"
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
The actual logic for the cart is in the cartReducer file:
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
import { CartItem } from "../types"
import { RootState } from "./store"
export type CartState = {
cartItems: CartItem[],
}
const initialState: CartState = {
cartItems: [],
}
const findItemIndex = (cartItems: CartItem[], item: CartItem) => {
return cartItems.findIndex(
cartItem => cartItem.product.id === item.product.id
)
}
const addItemReducer = (state: CartState, action: PayloadAction<CartItem>) => {
const itemIndex = findItemIndex(state.cartItems, action.payload)
if (itemIndex === -1) {
state.cartItems.push({ ...action.payload, quantity: 1 })
}
}
const removeItemReducer = (
state: CartState,
action: PayloadAction<CartItem>
) => {
const itemIndex = findItemIndex(state.cartItems, action.payload)
if (itemIndex > -1) {
state.cartItems.splice(itemIndex, 1)
}
}
const incrementQuantityReducer = (
state: CartState,
action: PayloadAction<CartItem>
) => {
const itemIndex = findItemIndex(state.cartItems, action.payload)
if (itemIndex > -1) {
state.cartItems[itemIndex].quantity++
}
}
const decrementQuantityReducer = (
state: CartState,
action: PayloadAction<CartItem>
) => {
const itemIndex = findItemIndex(state.cartItems, action.payload)
if (itemIndex > -1) {
state.cartItems[itemIndex].quantity--
// if the decremented item quantity is 0, remove the item
if (state.cartItems[itemIndex].quantity === 0) {
state.cartItems.splice(itemIndex, 1)
}
}
}
export const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
addItem: addItemReducer,
removeItem: removeItemReducer,
incrementQuantity: incrementQuantityReducer,
decrementQuantity: decrementQuantityReducer,
},
})
export const { addItem, removeItem, incrementQuantity, decrementQuantity } =
cartSlice.actions
export const selectCartItems = (state: RootState) => state.cart.cartItems
export const cartReducer = cartSlice.reducer
Using the store requires wrapping the parts that use it with a Provider and passing it to the store instance.
The top-level App component:
import * as React from "react"
import { Provider } from "react-redux"
import { PersistGate } from "redux-persist/integration/react"
import { persistor, store } from "./store"
function App() {
return (
<React.StrictMode>
<Provider store={store}>
<PersistGate persistor={persistor}>{/* other code */}</PersistGate>
</Provider>
</React.StrictMode>
)
}
export default App
Getting and updating the data is very similar to how it was done with React’s useReducer and Context API. This is how the Cart component implements it:
import { Link } from "react-router-dom"
import {
decrementQuantity,
incrementQuantity,
removeItem,
selectCartItems,
useAppDispatch,
useAppSelector,
} from "../store"
export const Cart = () => {
const cartItems = useAppSelector(selectCartItems)
// dispatch is the function that dispatches actions
const dispatch = useAppDispatch()
const handleIncrementQuantity = (cartItem: CartItem) => {
dispatch(incrementQuantity({ ...cartItem }))
}
const handleDecrementQuantity = (cartItem: CartItem) => {
dispatch(decrementQuantity({ ...cartItem }))
}
const handleRemoveItem = (cartItem: CartItem) => {
dispatch(removeItem({ ...cartItem }))
}
// the code below is simplified for clarity
return (
<table className="cart-table">
<tbody>
{cartItems.map(cartItem => (
<tr key={cartItem.product.id} className="product-row">
{/* other code */}
</tr>
))}
</tbody>
</table>
)
}
Redux is one of the long-lived standards for React shared state, which makes it a very safe choice. There is plenty of documentation for different cases, and most React developers are at least a bit familiar with it.
However, even with the Redux Toolkit extension, it still requires some boilerplate code, and when compared to some of the other solutions, it can look a bit clunky.
Implementation with Jotai
Resources:
Jotai is based on another state management library Recoil. It’s very lightweight, and simple, but still very powerful.
Main features:
-
There is no central store (but you can create one if you like)
-
It’s based on autonomous pieces of data that are called atoms
-
It is reactive — one atom can serve as an observable for other, derived atoms
-
Easy to use with React, but it also has a vanilla JS implementation.
It uses the WeakMap JS data structure, which makes it very performant - unused atoms are deleted from memory (through garbage collection) once they are not needed anymore.
WARNING: Because of this design, atoms rely on object reference equality. The implication is that creating them inside React render functions (components) requires additional handling (ex. useMemo) that will make sure they are not re-created on every render — that can lead to an infinite loop.
Usage
Atoms are created with the atom()
function, for example:
export const cartCountAtom = atom(get => {
return get(cartItemsAtom).length
})
Atoms can be updated in one of the 3 modes:
-
The updater function from the useAtom hook — similar to how React’s
useState
hook works. -
Write-only atom — it is the same as two-way data binding but helps centralize the logic.
-
Read-write atom — when you want the rules for reading and writing atoms to living in one place. It can be useful when you want to use a reducer pattern for updating the state. You can put your reducer function in the update part of the atom.
You don’t have to setup up a central store and use a Provider.
-
Atoms work without a Provider and central Store.
-
When using atomWithStorage, the data will be persisted in your localStorage (or sessionStorage if you prefer) — it will be used to seed the atoms on subsequent requests.
You can use a Provider and a central store(s) if you need to. It can be helpful in multiple cases:
-
Providing a different state for some component subtree.
-
Accepting initial values of atoms. This can be also achieved by syncing with the browser storage using atomWithStorage.
-
Clearing all atoms by remounting.
-
Creating a store outside React and passing it to the React app when it is initialized.
The code to create the store is much simpler than with Redux. Since there is no central store, and every piece of data is atomic, we can export the individual hooks that allow us to read and update the data and just use them anywhere in the app.
The Store code:
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
export const cartItemsAtom = atomWithStorage<CartItem[]>("cartItems", []);
export const cartCountAtom = atom((get) => {
return get(cartItemsAtom).length;
});
export const cartTotalAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce((total, item) => {
return total + item.product.price * item.quantity;
}, 0);
});
// Write only atom = this is two way data binding
// Be careful with this pattern, you may sometimes just do the logic in the component, or in the atom setter function (read/write atom)
export const addItemAtom = atom(null, (get, set, item: CartItem) => {
const items = get(cartItemsAtom);
const itemIndex = findItemIndex(items, item);
if (itemIndex === -1) {
set(cartItemsAtom, [...items, { ...item, quantity: 1 }]);
}
});
export const removeItemAtom = atom(null, (get, set, item: CartItem) => {
const items = get(cartItemsAtom);
const itemIndex = findItemIndex(items, item);
if (itemIndex > -1) {
items.splice(itemIndex, 1);
set(cartItemsAtom, [...items]);
}
});
export const incrementQuantityAtom = atom(null, (get, set, item: CartItem) => {
const items = get(cartItemsAtom);
const itemIndex = findItemIndex(items, item);
if (itemIndex > -1) {
items[itemIndex].quantity++;
set(cartItemsAtom, [...items]);
}
});
export const decrementQuantityAtom = atom(null, (get, set, item: CartItem) => {
const items = get(cartItemsAtom);
const itemIndex = findItemIndex(items, item);
if (itemIndex > -1) {
items[itemIndex].quantity--;
// if the decremented item quantity is 0, remove the item
if (items[itemIndex].quantity === 0) {
items.splice(itemIndex, 1);
}
set(cartItemsAtom, [...items]);
}
});
const findItemIndex = (cartItems: CartItem[], item: CartItem) => {
return cartItems.findIndex(
(cartItem) => cartItem.product.id === item.product.id
);
};
And this is how we can use them straight away in the Cart component:
import { useAtom } from "jotai"
import {
cartItemsAtom,
incrementQuantityAtom,
removeItemAtom,
decrementQuantityAtom,
cartTotalAtom,
} from "../store/store"
export const Cart = () => {
const [cartItems] = useAtom(cartItemsAtom)
const [cartTotal] = useAtom(cartTotalAtom)
const [, incrementQuantity] = useAtom(incrementQuantityAtom)
const [, decrementQuantity] = useAtom(decrementQuantityAtom)
const [, removeItem] = useAtom(removeItemAtom)
const handleIncrementQuantity = (cartItem: CartItem) => {
incrementQuantity(cartItem)
}
const handleDecrementQuantity = (cartItem: CartItem) => {
decrementQuantity({ ...cartItem })
}
const handleRemoveItem = (cartItem: CartItem) => {
removeItem({ ...cartItem })
}
// some code is hidden for clarity
return (
<table className="cart-table">
<tbody>
{cartItems.map(cartItem => (
<tr key={cartItem.product.id} className="product-row">
{/* other code*/}
</tr>
))}
</tbody>
</table>
)
}
As you can see, this is very straightforward and simple when compared to Redux. The downside might be the odd API, but actually, it is very easy to get used to it, and Jotai has an excellent interactive tutorial to help with the learning curve.
Implementation with Zustand
Resources:
Zustand is easy to set up and work with. It can be modeled after the reducer pattern, and the store setup is very similar to Redux. However, it fixes a lot of issues that Redux has:
-
It doesn’t require a lot of boilerplate code (even less than Redux Toolkit).
-
It handles async actions out of the box.
-
It doesn’t require a Provider (but you can use it if you need to)
-
It’s un-opinionated and it lets the developer decide how to set up the store.
-
It has a lot of integrations, plugins, and custom hooks, including a Jotai integration.
Usage
The store creation technically is similar to Redux Toolkit, however, there are some conceptual differences:
-
You don’t have to use one centralized store — you can create many stores, and each one of them is a separate entity.
-
No need for the dispatch pattern — functions updating the store are part of the store object itself and can be accessed using a selector.
-
Easy storage persistence with just a single persist function that wraps the store — no need for 3rd party libraries.
-
You can create your own Context and Provider to initialize store values for a component subtree.
The Store code:
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { CartItem } from "../types";
export type CartState = {
cartItems: CartItem[];
};
export type CartActions = {
addItem: (item: CartItem) => void;
removeItem: (item: CartItem) => void;
incrementQuantity: (item: CartItem) => void;
decrementQuantity: (item: CartItem) => void;
};
export const useCartStore = create<CartState & CartActions>()(
persist(
(set, get) => ({
cartItems: [],
addItem: (item: CartItem) => {
const items = get().cartItems;
const itemIndex = findItemIndex(items, item);
if (itemIndex === -1) {
set({ cartItems: [...items, { ...item, quantity: 1 }] });
}
},
removeItem: (item: CartItem) => {
const items = get().cartItems;
const itemIndex = findItemIndex(items, item);
if (itemIndex > -1) {
items.splice(itemIndex, 1);
set({ cartItems: [...items] });
}
},
incrementQuantity: (item: CartItem) => {
const items = get().cartItems;
const itemIndex = findItemIndex(items, item);
if (itemIndex > -1) {
items[itemIndex].quantity++;
set({ cartItems: [...items] });
}
},
decrementQuantity: (item: CartItem) => {
const items = get().cartItems;
const itemIndex = findItemIndex(items, item);
if (itemIndex > -1) {
items[itemIndex].quantity--;
// if the decremented item quantity is 0, remove the item
if (items[itemIndex].quantity === 0) {
items.splice(itemIndex, 1);
}
set({ cartItems: [...items] });
}
},
}),
{ name: "cart-storage" }
)
);
const findItemIndex = (cartItems: CartItem[], item: CartItem) => {
return cartItems.findIndex(
(cartItem) => cartItem.product.id === item.product.id
);
};
You may notice that instead of creating contexts, providers, selectors, and whatnot, with Zustand we have only one hook which is created by the create() function. If you are wondering if there is a possibility to combine a store from different “slices” like in Redux — yes, you can do it as well. It’s all described in the excellent documentation linked in the resources.
To access the store data and updater functions, we just have to call the useCartStore hook that we created and pass to it a selector — a function that takes the state as a parameter and returns the piece (pieces) of data that is needed.
The Cart component shows this behavior:
import { useCartStore } from "../store/store"
export const Cart = () => {
const cartItems = useCartStore(state => state.cartItems)
const removeItem = useCartStore(state => state.removeItem)
const incrementQuantity = useCartStore(state => state.incrementQuantity)
const decrementQuantity = useCartStore(state => state.decrementQuantity)
const handleIncrementQuantity = (cartItem: CartItem) => {
incrementQuantity({ ...cartItem })
}
const handleDecrementQuantity = (cartItem: CartItem) => {
decrementQuantity({ ...cartItem })
}
const handleRemoveItem = (cartItem: CartItem) => {
removeItem({ ...cartItem })
}
// some code is hidden for clarity
return (
<table className="cart-table">
<tbody>
{cartItems.map(cartItem => (
<tr key={cartItem.product.id} className="product-row">
{/* other code*/}
</tr>
))}
</tbody>
</table>
)
}
As you can see, this is also much more straightforward than using Redux. The selectors can be hidden behind custom hooks, or even created automatically for each piece of the state. Head on to Zustand’s documentation for more.
Summary
The 3 popular libraries that we explored here all have some advantages. In the end, like with everything in programming, it’s about the specific use case, team preferences, and most of all — the tradeoffs that each library presents. For example, below are my main thoughts from this experiment:
-
Redux Toolkit should be easy to adopt by most React developers because Redux has been around for longer than the other options. Unfortunately, even though this library is a big improvement to Redux, it still needs some boilerplate code and needs to be organized into one root store, which could potentially lead to issues in some advanced cases.
-
Jotai is very straightforward, and it has atomic pieces of data instead of a central store. This makes it very easy to colocate the data where it really belongs. Its reactive patterns are powerful, yet simple to implement.
-
Zustand is versatile, and it can be used almost as a meta-platform for any state management pattern that you may think of. It also handles many edge cases out of the box.
It’s hard to say if there is any clear winner, but in my personal projects, I will be more inclined to use Jotai or Zustand, as they are easier to set up and more versatile than Redux Toolkit. In the case of working within a bigger team on a serious application, I would definitely do more in-depth research before committing to any of them.
I hope you now have more insight into the specifics of the different state management libraries.