I’ve been using Zustand heavily for https://chessmadra.com, and learned a lot about how best to use the library. I think now I have a really nice setup that has better ergonomics than you’d get out-of-the-box. This isn’t really a tutorial, more like a sales pitch for my approach and then the source code if you want to mimic it.

One store, easily select and update parts of it

I like the idea of having a single store. There are often actions that have to affect multiple slices of state, and it’s nice to be able to treat the store as one entity in those cases. On the other hand, individual components almost always just care about a single slice of the store. So I have a single store, then hooks for individual pieces of that store.

export interface AppState {
  repertoireState: RepertoireState;
  adminState: AdminState;
  // ...
  debugState: DebugState;
  navigationState: NavigationState;
  userState: UserState;
}

Components call hooks that only select from one slice of that state, like this hook for the repertoireState slice up above:

const [
  isAddingPendingLine,
  pendingLineHasConflictingMoves,
] = useRepertoireState((s) => [
  s.isAddingPendingLine,
  s.pendingLineHasConflictingMoves,
]);

Similarly, in the functions defined within the state, you just get the one slice of state:

{
  updateEloRange: (range: [number, number]) =>
    set(([s]) => {
      // s is of type RepertoireState
      s.isUpdatingEloRange = true;
      // ...
    }),
}

If you want to update the root state, or get a sibling slice, that set method’s callback is actually called with a stack of state slices, so getting access to the AppState is as simple as changing:

    set(([repertoireState]) => {
      // Do stuff
    })

to:

    set(([repertoireState, appState]) => {
      // Do stuff with both
    })

Performant equality checks, and debugging re-renders

By default zustand uses strict equality checking when the state has been updated, which ends up always re-rendering in cases like this:

const [foo, bar] = useStore((s) => [s.foo, s.bar]);

The most common remedy here is to slap a shallow on there:

import shallow from 'zustand/shallow'
const [foo, bar] = useStore((s) => [s.foo, s.bar], shallow);

This works really well, with a couple drawbacks. One is that I don’t trust myself to always remember to put shallow. The other is that when a component does re-render, it’s anonying to find out why. React devtools will just say that a hook was changed, and zustand has no way of telling you what piece was updated.

So I wrote an equality check function, and the hooks all use that by default. The hook is called without an equality check specified:

const [foo, bar] = useRepertoireState((s) => [s.foo, s.bar]);

And behind the scenes it’s going to do a deep equality check. That makes it ergonomic and performant, but the big upside of this approach comes when trying to debug re-renders. Just pass a flag like this:

const [
  //...
] = useRepertoireState((s) => [
  //...
], true); // <-- flag to debug this hook

Then, when the component re-renders, I get a message in the console about what path in the hook caused the re-render:

Equality debug log

The other nice thing about this is that I can override what the equality check does for different types. For example, I’m using the fantastic chess.ts library, which has a class, Chess. When the position gets updated I want my components to update, but I don’t want to check everything in that class, so in my equality function I have a check like this:

if (a instanceof Chess && b instanceof Chess) {
  return a.fen() === b.fen();
}

Which is both more performant and easier to reason about.

Ergonomically calling other functions in the state

The biggest stumbling block I’ve hit with Zustand is how to call state functions from other state functions. The naive way to do this would be to just call the function, right? Well if you’re using immer, this will call produce again, and only the inner call will succeed. Or maybe it was the outer one, in any case it was a footgun that I hit frequently. I’ve tried a couple approaches here, but finally hit one that I really like. I never have to think about whether there’s an existing state update happening or not.

  {
    startBrowsing: (side: Side) =>
      set(([s]) => {
        // ...
        s.setBreadcrumbs(breadcrumbs);
        // ...
        s.browsingState.onPositionUpdate();
      }),
  }

So here I’m calling a couple different functions on the state, without having to pass the current state in, or any other check. The magic here is in the set function. Behind the scenes there’s a global pendingState object. So when we hit a set call, it will either get a new proxy from the zustand-provided, immer-powered set, or if that’s been called already, it will return the pending proxy.

Quick updates

Sometimes state updates don’t seem to deserve an entire function. I’ve got a little function, quick defined on each slice of state, that lets me do little updates without having to add a function to the state and state interface:

const quick = useRepertoireState((s) => s.quick);

return (
  <Button
    onPress={() => {
      quick((s) => {
        s.overviewState.isShowingShareModal = true;
      });
    }}
  >
  </Button>
);

This is also really convenient for prototyping. This uses that set function from above too, so you can call other state functions just like you could in “proper” state functions.

Source

The custom equality check stuff, the pending state stuff, and the custom hooks all live in the app_state.tsx file, among other things.

The user state is a small slice of state but uses the common patterns. For a (much) bigger example, check out the repertoire state.

The share modal is a manageable little component that uses one of the slice hooks.

Then finally here’s the quick function. Slices of state add it like this.