Working with Zustand
- #1: Working with Zustand
- #2: Zustand and React Context
Global (client) state management wasn’t always like it is today. I distinctly remember a time when our best option was Redux with higher order components using connect (opens in a new window) plus mapStateToProps and mapDispatchToProps.
Even the context api, initially, wasn’t as ergonomic to use (pun intended), as it only supported render props when it came out (opens in a new window).
Of course, everything changed when hooks (opens in a new window) were released. Not only did existing solutions become much easier to use, but new ones were born.
One of them that I quickly grew to like was Zustand (opens in a new window). It’s a tiny library (v4.1.4 is 1.1kB Minified + Gzipped) that provides a simple API to create global state stores and subscribe to them via selectors. This pattern is conceptually similar to what Redux is doing, which is already known by many developers.
Much like React itself, Zustand is not opinionated. You can combine it with immer (opens in a new window) if you want to. You can dispatch actions (opens in a new window), but you don’t have to. It has powerful middlewares (opens in a new window), but they are totally optional.
I do like that about the library. It provides the bear minimum to get you started (hence the logo), and it’s flexible enough to scale to your needs. However, it does leave you with a bunch of decisions to make yourself - analogous to how React doesn’t prescribe a way to do styling.
It’s also not “magical”. It doesn’t track which fields you are using, as some similar libraries do; you have to subscribe “manually” with selectors. And in my experience, that’s a good thing because it enforces being very explicit about your dependencies, which pays off as an app grows, even though it might be a little bit more to write.
I’ve been working with Zustand since 2018, in projects small and large. I’ve also contributed (opens in a new window) a bit to the library. Here are a couple of tips I’ve picked up along the way:
This is my number one tip for working with… everything in React, really. I’ve listed many advantages of custom hooks before, and I believe they apply to working with Zustand as well.
// ⬇️ not exported, so that no one can subscribe to the entire storeconst useBearStore = create((set) => ({ bears: 0, fish: 0, increasePopulation: (by) => set((state) => ({ bears: state.bears + by })), eatFish: () => set((state) => ({ fish: state.fish - 1 })), removeAllBears: () => set({ bears: 0 }),}))
// 💡 exported - consumers don't need to write selectorsexport const useBears = () => useBearStore((state) => state.bears)They’ll give you a cleaner interface, and you don’t need to write the selector repeatedly everywhere you want to subscribe to just one value in the store. Also, it avoids accidentally subscribing to the entire store:
// ❌ we could do this if useBearStore was exportedconst { bears } = useBearStore()While the result might be the same, you’ll get the number of bears, the code above will subscribe you to the entire store, which means that your component will be informed about any state update, and therefore re-rendered, even if bears did not change, e.g. because someone ate a fish.
While selectors are optional in Zustand, I think they should always be used. Even if we have a store with just a single state value, I’d write a custom hook solely to be able to add more state in the future.
This is already explained in the docs (opens in a new window), so I’ll keep it brief, but it’s still quite important because it can lead to degraded rendering performance if you “get it wrong”.
Zustand decides when to inform your component that the state it is interested in has changed, by comparing the result of the selector with the result of the previous render. Per default, it does so with a strict equality check (opens in a new window).
Effectively, this means that selectors have to return stable results. If you return a new Array or Object, it will always be considered a change, even if the content is the same:
// 🚨 selector returns a new Object in every invocationconst { bears, fish } = useBearStore((state) => ({ bears: state.bears, fish: state.fish,}))
// 😮 so these two are equivalentconst { bears, fish } = useBearStore()If you want to return an Object or Array from a selector, you can adjust the comparison function to use shallow comparison:
import shallow from 'zustand/shallow'
// ⬇️ much better, because optimizedconst { bears, fish } = useBearStore( (state) => ({ bears: state.bears, fish: state.fish }), shallow)However, I much prefer the simplicity of just exporting two separate selectors:
export const useBears = () => useBearStore((state) => state.bears)export const useFish = () => useBearStore((state) => state.fish)If a component really needs both values, it can consume both hooks.
Actions are functions which update values in your store. These are static and never change, so they aren’t technically “state”. Organising them into a separate object in our store will allow us to expose them as a single hook to be used in any our components without any impact on performance:
const useBearStore = create((set) => ({ bears: 0, fish: 0, // ⬇️ separate "namespace" for actions actions: { increasePopulation: (by) => set((state) => ({ bears: state.bears + by })), eatFish: () => set((state) => ({ fish: state.fish - 1 })), removeAllBears: () => set({ bears: 0 }), },}))
export const useBears = () => useBearStore((state) => state.bears)export const useFish = () => useBearStore((state) => state.fish)
// 🎉 one selector for all our actionsexport const useBearActions = () => useBearStore((state) => state.actions)Note that it’s totally fine to now destruct actions and only “use” one of them:
const { increasePopulation } = useBearActions()This might sound contradictory to the “atomic selectors” tip above, but it really isn’t. As actions never change, it doesn’t matter that we subscribe to “all of them”. The actions object can be seen as a single atomic piece.
This is a general tip, no matter if you’re working with useReducer (opens in a new window), Redux or Zustand. In fact, this tip is straight from the magnificent Redux style guide (opens in a new window). It will help you keep your business logic inside your store, and not in your components.
The examples above have already been using this pattern - the logic (e.g. “increase population”) lives in the store. The component just calls the action, and the store decides what to do with it.
Unlike Redux, where you’re supposed to have a single store for your whole app, Zustand encourages you to have multiple, small stores. Each store can be responsible for a single piece of state. If you need to combine them, you can do that with - of course - custom hooks:
const currentUser = useCredentialsStore((state) => state.currentUser)const user = useUsersStore((state) => state.users[currentUser])Note: Zustand does have another way to combine stores into slices (opens in a new window), but I’ve never needed that. It doesn’t look super straightforward to me, especially when TypeScript is involved. If I specifically needed that, I would rather opt for Redux Toolkit (opens in a new window).
I honestly haven’t needed to combine multiple Zustand stores very often, because most of the state in apps is either server or url state. I’m far more likely to combine a Zustand store with useQuery or useParams, for example, than I am to combine two separate stores.
Once again, the same principle applies: if you need to combine another hook with a Zustand store, custom hooks are your best friend:
const useFilterStore = create((set) => ({ applied: [], actions: { addFilter: (filter) => set((state) => ({ applied: [...state.applied, filter] })), },}))
export const useAppliedFilters = () => useFilterStore((state) => state.applied)
export const useFiltersActions = () => useFilterStore((state) => state.actions)
// 🚀 combine the zustand store with a queryexport const useFilteredTodos = () => { const filters = useAppliedFilters() return useQuery({ queryKey: ['todos', filters], queryFn: () => getTodos(filters), })}Here, the applied filters drive the query, because they are part of the query key. Every time you call addFilter, which you can do from anywhere in your UI, you will automatically trigger a new query, which could also be used from anywhere in your UI. I find this to be pretty declarative and minimal, without being too magical.
That’s it for today. Feel free to reach out to me on bluesky (opens in a new window) if you have any questions, or just leave a comment below. ⬇️