Global State with Zustand

Zustand is a delightfully easy-to-use library for implementing global state in your React applications.

Global State with Zustand
Zustand includes only the "bear" necessities for state.

When I started learning React in 2016, the de facto library for managing global state was Redux. It has a steep learning curve and requires quite a bit of boilerplate to get started, but 6 years later and Redux still remains one of the most popular packages to include in a complex frontend application. There are so many supplemental middleware packages to make development easier, such as Redux Thunk for asynchronous action creators, and recently the Redux Toolkit has become "the official, opinionated, batteries-included toolset for efficient Redux development."

But for smaller applications, Redux is overkill. There are a lot of alternatives to choose from, and recently I had a very good experience working with Zustand. In this tutorial, I will show you how to get started with it and also share my special recipe that you won't find in the official documentation!

The Basics

💡
The tutorial assumes you have a React application set up and running. You should also install Zustand with npm install --save zustand in the root of your application directory.

So let's get started by building our state object for a dog watching application. Why you should ever need an app for watching dogs is beyond me, but I wanted to have a simple example for this tutorial. In real life, just live in the present moment and enjoy watching dogs playing in the park!

The create function from Zustand returns a React hook that can be imported and used in functional components. Hooks can only be used inside of functional components, but later in this tutorial I'll show you how you can use your Zustand store even if you are using class components.

create takes a function as the first argument and returns an object, which represents the state tree. The returned object can have any number of keys with any types of values. Let's go over each of the properties and methods in the useStore object.

dogs is a simple property and its starting value is 0.

The create function's first argument is another function, and that function has two arguments: set and get. In the toString() method, I call get().dogs to retrieve the current value of dogs and return a formatted string based on the value.

In incrementDogs, I update the value of dogs by adding one, and I accomplish this by using the set method. As you can see, the set method takes a function as an argument and returns an object, which is then merged with the entire global state object. That merging behavior is very important because it will only update the value of dogs but no other properties in the global state object are affected.

Additionally, I update the value with state.dogs + 1, which is equivalent to get().dogs + 1. The set's callback function takes one argument, state, which can be accessed inside of set.

decrementDogs subtracts 1 from our total number of dogs, but it checks if dogs is greater than 0 before any updates occur. It wouldn't make any sense if we said "Today I saw negative 3 dogs at the park", so I prevent this from happening in our dog counter.

startOver is a simple method that updates the dogs value to 0.

And finally, I implement a fake saveProgress method to demonstrate that Zustand doesn't care if functions are async or not. Just use the async/await keywords, and that's it! No special middleware required.

Now that we've gone over the state object, let's import and use it inside of a React component.

First, I import the useStore hook that I created in the last file. It's a React hook, so I have to call it inside the functional component before the return statement.

const state = useStore() is all that's needed! Now all the properties and methods in the global state object are accessible from the state variable. As you can see, the component calls state.toString() inside the header element, and the buttons' onClick event handlers call the appropriate method. So easy!

💡
Now keep in mind that fetching everything from the Zustand store will actually re-render the component every time a property is updated with the set method. This really isn't a problem with this very simple example because dogs is the only property that gets updated, but your applications will likely have more properties that get updated so you might want to avoid this behavior. Let's see how in the next example.

For demonstration purposes, I have moved the buttons into their own component, isolated from the header text. In this version, we are creating multiple state slices by passing in a selector function to the useState hook. This is a very efficient method for making atomic selections (in other words, picking a single property or method from the Zustand state object). In addition, if the dogs value updates then this component won't re-render because dogs isn't selected in this buttons-only component.

Improve it with an HOC

As you can see, it's really easy to get started with Zustand, and the documentation on the GitHub repo gives even more examples than this tutorial. There are a few drawbacks to using Zustand in the examples I have given so far, but luckily for you, I've come up with a solution that addresses all the issues I have with it. Let's first discuss the shortcomings:

  1. The result of Zustand's create function is a React hook, which means it can only be used in functional components. A lot of React apps are switching over to functional components anyways, but what if you're still using classes?
  2. It's very easy to select either all the properties from Zustand, or just one property at a time. But what if you wanted to construct a single object with multiple state selections, similar to Redux's mapStateToProps function?
  3. In the examples that both the documentation and I have given, we are tightly coupling a component to the Zustand global state. For this simple app, this is probably OK but it doesn't really make the components reusable. If our components depend on a dogs value, should our component really care about the source of this data? Does it care if it's from a hook or just good, old-fashioned props? Coupling a component to a global state object makes it harder to write unit tests as well!

We can address all 3 of these issues by wrapping our component with an HOC, or higher order component. In fact, if you've ever used react-redux's connect HOC, then you know exactly what's going on here!

In a moment, I'll show you how I implement the withState HOC, but right now let's just focus on how we're using it.

First, I import withState and invoke it at the bottom of the file with an export default statement. The argument we pass to it is a selector function, which behaves exactly the same as the mapStateToProps function that gets passed to react-redux. I'm simply choosing the properties from the Zustand state object and mapping it to properties that will be passed to our component via props.

The mappings can have the exact same names, or I can provide entirely new names. In this case, I have decided to rename toString to numDogsToString and now this method will be available via props. So instead of using state.toString() as you saw in the first example, I can now use props.numDogsToString().

withState(selectors) actually returns a function, so I invoke the returned function and pass in the component I want to "connect" to the Zustand global state. The result looks like withState(selectors)(DogWatcher). Now let's see how I implement the withState HOC.

In order to construct a new object with multiple state picks, I "instruct" Zustand to diff the object shallowly by passing in Zustand's shallow function, which I import at the top of the file.

Now remember, when you invoke withState you have to pass in a selector function that returns an object. This object's keys are mapped to the component's props, and there's a possibility of having colliding or duplicate prop names. For example:

// Default export connected to Zustand
import DogWatcher from './dogWatcher';

<SomeParentComponent>
  <DogWatcher
    numDogsToString={() => console.log('Could possibly be overwritten')}
  />
</SomeParentComponent>
import withState from './withState';

function DogWatcher(props) {
	return <h1>{props.numDogsToString()}</h1>
}

const selectors = (state) => ({
  numDogsToString: state.toString
  /** This collides with props passed from <SomeParentComponent> */
  /** This should be renamed. */
});

export default withState(selectors)(DogWatcher);

So for this reason, I implement a very simple check that alerts the developer to colliding names with a function called hasUniqueKeys. And then finally, the HOC returns the component along with its existing props as well as the Zustand store also as props.

That's it! For small to mid-sized apps that has a relatively small global state tree, Zustand is a very easy and fun solution to use. Its size is relatively small and will not bloat the size of your React bundle.