By the last monthly post, our sample app, Timo, was finally ready for some experiments. So in this post, let's look at Zustand to see why it has become a favorite in the community. To do this, I will refactor Timo to use Zustand so that we can see what it is like in practice.

Hand drawn sketch of a chat bubble with the word Zustand inside

What is Zustand?

Zustand is a small state management library that provides React hooks to manage the state. It is based on the Flux pattern like Redux but similar to how Redux simplified the original Flux pattern, Zustand simplifies it even further. For example, here is how you would set up a simple store with a count value and an action to increase it.

import { create } from 'zustand'

const useCountStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));

To achieve the same result with Redux Toolkit, you would have had to set up a new slice, add that new slice to the root reducer and also set up the creation of the store and its provider if it hasn't been set up already. With Zustand, you can access the count value and the increment action from the generated useCountStore hook.

const Counter = () => {
const countStore = useCountStore();
return (
<div>
<p>
Count:
{countStore.count}
</p>
<button onClick={countStore.increment}>Add one</button>
</div>
)
}

Since Zustand mostly consists of a few React hooks, its npm package is only 3.1KB (minified) in size which is 44 times smaller than the base React packages (react - 6.4KB (minified), react-dom - 130.1KB (minified)). It is 13 times smaller than Redux Toolkit's npm package (40.1KB minified) but that is not a fair comparison since Redux Toolkit has more functionality.

Screenshot of Zustand on Bundlephobia
Zustand on Bundlephobia

Refactoring Timo to use Zustand

Since Timo was already using Tanstack query, I only had to rework the user authentication and profile storage to use Zustand. In the plain React version and the Tanstack query version of Timo, this was done using React context. Refactoring this to Zustand took me around an hour. In many cases, it was almost a drop-in replacement since I already used a hook to access the underlying Context value.

Screenshot of code diff in VS code from refactoring to Zustand
A drop-in replacement (if you are lucky)

The tedious part of the refactor was changing the components in the common package. I wanted to keep separate copies of Timo with each of the libraries we took a look at so I set up the repo as a mono repo with each version stored as a separate folder. To cut down on duplicate code, I also created a common package to store the code that was used by several versions of Timo.

Sadly, some of the components in the common package used React Context. They won't work in the Zustand version of Timo because I was using Zustand instead of React Context. So I had to refactor these components to be pure and then refactor the previous versions to work with these changes. I still kept the components that depended on Context in the common package but moved them to a separate folder to show that they were not pure.

<!-- The TopBar component in the common package -->
common/
├── components/
│ └── TopBar/
│ ├── TopBar.jsx <!-- Pure component. Can be used wherever. -->
│ ├── TopBar.module.css
│ └── index.js
└── contextualComponents/
└── TopBarWithUser.jsx <!-- Uses pure TopBar above. Can only be used within Context. -->

The other thing that stumped me for a moment was handling side effects in Zustand. It seemed to have the concept of middleware but I couldn't find any docs about how to write our own. The docs only had instructions about using the middleware that came with the library. Why did this matter? Well, since I was using HTTP only cookies to authenticate with the API, I had to make a request to the API when the app started to verify whether the user was already logged in. I could write an action using Zustand to make this request but how could I run it whenever the app started?

import { create } from 'zustand';
import { getUser } from '@timo/common/api';

const useUserStore = create((set) => ({
data: null,
error: null,
fetchUser: async () => { // How to run this every time the app starts?
try {
const user = await getUser();
set({ data: user, error: null });
} catch (error) {
set({ error });
}
},
}));

export default useUserStore;

The first solution that came to my mind was to create a custom wrapper component that had a useEffect hook to run the action. But useEffect hooks are evil and I try to avoid them as much as I can.

import useUserStore from '../zustand/useUserStore';

const AuthChecker = () => {
const fetchUser = useUserStore((state) => state.fetchUser);

useEffect(() => { // Nope, this won't do
fetchUser();
}, [])

return null;
}

But then I realized that we can interact with the Zustand store outside of React. So we can run the action immediately after the store is declared. Another plus of this approach is that the API request is only made once during development. If we used the useEffect hook instead, like the previous versions of Timo, the request would be made twice since React triggers useEffect hooks twice when React Strict Mode is enabled during development.

const useUserStore = create((set) => ({
...
}));

// Just fetch the user immediately after initializing the store
useUserStore.getState().fetchUser();

It's interesting how getting used to the React way leads you to overlook simpler solutions in plain JS to the same problem.

My impressions of Zustand

After refactoring Timo to use Zustand, I have to say that I really like Zustand's generated hooks. They are easy to get started with and can easily be fine-tuned to prevent re-renders. This makes it a perfect fit for managing state that has to be shared across several parts of a React app.

// Returns the entire store.
// State values and actions can be accessed as properties (e.g. countStore.count, countStore.increment())
// Triggers re-renders when any value in the store changes
const countStore = useCountStore();

// Returns only the count
// Triggers re-renders ONLY when the count changes
const count = useCountStore(state => state.count);

// Returns only the increment action
// Never triggers re-renders, stable identity
// Ready to run, no need to pass in the action type, unlike useReducer's dispatch
const increment = useCountStore(state => state.increment);

I also like how Zustand tackles async operations. It's just calling set with the result. Then the action can be run in a synchronous context. No middleware. No promise chains.

// packages/zustand/src/zustand/useUserStore.js
import { create } from 'zustand';
import { UserStatus } from '@timo/common/context/UserContextProvider';
import { getUser } from '@timo/common/api';

const useUserStore = create((set) => ({
status: UserStatus.UNKNOWN,
data: null,
error: null,
fetchUser: async () => { // Async action
try {
const user = await getUser(); // Async function that makes API call
set({ status: UserStatus.AUTHENTICATED, data: user, error: null });
} catch (error) {
set({ status: UserStatus.UNAUTHENTICATED, error });
}
},
}));

The biggest benefit of switching away from React Context

The biggest issue with using React Context to share state across several components is that any changes to the state will cause all subscribed components to re-render. Moreover, changing the Context value also causes child components within the context provider to re-render. Since the Context provider usually wraps the entire app, the entire app re-renders. (You can find these issues explained in more detail here)

Timo used to have this problem in the profile screen where saving the user details would cause the entire app to re-render.

Recording of the browser showing re-renders on Timo's profile screen
Saving changes to the profile causes the entire app to re-render

You can overcome these issues to some degree by using memoization and multiple contexts but it is very tedious. But by using a state management library like Zustand, you can avoid these issues more easily.

Zustand doesn't require you to wrap the entire app with a provider component or HOC and it sends updates directly to the generated hooks. So the entire app doesn't re-render when the state changes. Since you can also subscribe to specific state values and actions with Zustand, you can also make components re-render only when the state relevant to them changes.

So to optimize Timo's profile screen, I only had to add a few selectors to the store hooks to stop them from re-rendering unnecessarily.

// packages/zustand/src/routes/Profile/Profile.jsx
const Profile = () => {
const clearUser = useUserStore((state) => state.clearUser);
...
}
// packages/zustand/src/routes/Profile/sections/ChangePassword.jsx
const ChangePasswordSection = () => {
const username = useUserStore((state) => state.data?.username);
...
}

and viola!

Recording of the browser showing re-renders on Timo's profile screen after optimizing with Zustand
Saving changes only re-renders the components that should update

Summary

Zustand gets a thumbs-up from me. It is a light package that is well thought out and approachable, especially if you are familiar with Redux and the Flux / Redux pattern. This is probably why the community has fallen in love with it.

But what if you hate the Flux pattern and anything else like it? In next month's post, let's take a look at the atomic pattern for managing state which also came from Facebook/Meta and at Jotai which is taking over from Recoil as the de-facto library for this pattern.

Prabashwara Seneviratne (bash)

Written by

Prabashwara Seneviratne (bash)

Lead frontend developer