Skip to main content
How to use Zustand
19 min read

How to use Zustand

This article was last updated on July 31, 2024, to add sections for Advanced State Management Techniques and Custom Hooks for Zustand.

Introduction

Redux changed the game in the global management system. It was so successful that it was widely adopted and used as the ideal state management system in any framework. Not only in the framework but its principle still serves greatly in software development. Almost all developers have used Redux to manage their global state, and we can all attest to how powerful, fast, and maintainable it is to use Redux as the global state management tool. It makes debugging very easy and our app is predictable.

Yes, Redux took the grand stage in global state management, but there is a new kid on the block. This new kid is poised, and ready to capture the audience of the global state management system. It comes packing a powerful punch and is armed to the teeth. Its name is Zustand. With the arrival of Zustand and the goodies it brings, will it suffice to say that Redux reign is coming to an end?

In this article, we will deep dive into Zustand. We will learn about this kid, to understand how it works, its state management technique, and how it will take the state management world by storm.

Steps we'll cover:

What is Zustand?

A small, fast, and scalable barebones state-management solution using simplified flux principles. Has a comfy API based on hooks, and isn't boilerplates or opinionated. https://github.com/pmndrs/zustand

Zustand is a small unopinionated state management library built by Jotai and React-spring. It has a comfy API based on hooks, and isn't opinionated. Zustand is open-source with a large community of users and support developers working round the clock to make Zustand stable. It sits on Github with 30,000+ stars.

Zustand is very different from Redux in terms of how it is used. Zustand is simple and un-opinionated, it does not wrap your app in content providers like how we do with React-Redux. It mainly uses hooks as a means of communicating back and forth with the state. At its core, Zustand embraces the concept of a single source of truth, where the entire application state is stored in a centralized store. This store is composed of state slices, which are individual units of state that represent different parts of the application. Each state slice is defined as a separate store, allowing for modularity and encapsulation of related state properties and their associated actions. Working with Zustand, you will need less boilerplate for it and its state management is centralized and action-based.

Zustand promotes immutability by default, ensuring that state updates are handled in an immutable fashion. When updating the state, you create a new state object rather than modifying the existing state directly. This approach simplifies state management, prevents common mutation-related bugs, and enables efficient change detection and re-rendering in React components.

Another notable feature of Zustand is its built-in support for subscriptions and selective reactivity. Components can subscribe to specific state slices and be automatically re-rendered when those slices change. Zustand uses a fine-grained dependency tracking mechanism based on proxies, allowing for highly efficient updates and minimizing unnecessary re-renders.

In the next section, we will learn how to install and use Zustand in our project.

Getting started with Zustand

We know that Zustand is a JavaScript library that runs on Nodejs. So we will need some basic tools to be installed on your machine before we start.

  • Nodejs: We need the Nodejs binaries installed in our system. Go over to https://nodejs.org/en/download and install the binaries meant for your machine.
  • npm or yarn: These are Node Package Managers, they help in maintaining and managing the dependencies, and the Nodejs environment of our project. npm comes bundled with the Nodejs binaries, so once you install Nodejs you don't need a separate installation for npm. Yarn can be installed by running npm i yarn -g.

Let's create a Nodejs project:

mkdir zustand-prj
cd zustand-prj
npm init --y
  • mkdir zustand-prj: This command creates a new directory named "zustand-prj" in the current location. It is equivalent to "make directory." The "zu
  • cd zustand-prj: This command changes the current working directory to "zustand-prj." By running this command, you will navigate into the newly created directory.
  • npm init --y: This command initializes a new Node.js project within the "zustand-prj" directory. The npm init command is used to generate a package.json file, which is a manifest file that describes your project's metadata and dependencies. The --y flag is added to automatically accept all default options without prompting for user input. It is a shortcut for answering "yes" to all the initialization questions.

To install the zustand library, we run the below command:

npm install zustand # or yarn add zustand

This command installs the zustand library in our project.

To use zustand, we have to import a create function:

import { create } from "zustand";

This function is called with a callback function and it returns a custom hook. The callback function passed to it is where we will define our state and the functions we can use to manipulate the state. The state and the functions are all in an object returned by this callback function.

Let's see an example:

const useCounter = create((set) => {
return {
counter: 0,
incrCounter: () => set((state) => ({ counter: state.counter + 1 })),
};
});

See that the create function, passes a set function to the callback function. This set function is a function used to manipulate the state in the store. States in zustand can be primitives, objects, or functions. In our above example, we have two states in our store: counter, and incrCounter. The useCounter is a custom hook, we can use this hook in our components and we will be able to get the latest state in them. If we use the hook in components A, B, and C. Any change done to the state in B will be reflected in both A and C, and they will all re-render to reflect the new changes.

The custom hook returned by the create acts similarly to useAppSelector in React-Redux, it lets you select a slice of state from the store. You call the hook and pass it a callback function. This function is called by the hook internally and passes the current state to it. So we will then get this state and return the part of the state we want.

Let's see an example.

const counter = useCounter((state) => state.counter);

See that we called the useCounter hook and passed a callback function to it. Then, we expect a state from the hook and then return the counter part of the state.

We can then, display the counter:

const DisplayCounter = () => {
const counter = useCounter((state) => state.counter);
return <div>Counter: {counter}</div>;
};

Now, we want to create a component where we can increase the value of the counter state.

const CounterControl = () => {
const incrCounter = useCounter((state) => state.incrCounter);

return (
<div>
<button onClick={incrCounter}>Incr. Counter</button>
</div>
);
};

This is a separate component from where we increase the value of the counter state. See that we sliced out the incrState function from the state, and we set it to the onClick event of the button. This will increase the counter state when the button is clicked. See how the components are independent of each yet they can "see" the current state from the store. Whenever we click the Incr. Counter button in the CounterControl component, the DisplayComponent will re-render to display the newest counter state value.

Let's see how we use them:

const App = () => {
return (
<>
<DisplayCounter />
<CounterControl />
</>
);
};

They are independent of each other yet magically connected by zustand. This gives React-Redux a run for its money because trying to re-create this small state in Redux-React will take more code to set up:

  • First, we will create a store.
  • We will wrap either the App component or its children in a Content Provider and pass the store to the Context Provider via a store props.
  • We will import useSelector, useDispatch in any component we wish to use in the store.
  • To get a slice of the state we will call the useSelector with a callback function.
  • To dispatch an action to the store, we will use the useDispatch hook.

It's quite lengthy, but with Zustand it's oversimplified.

Returning the whole state Now, when we call the custom hook returned by the create function without a callback function, the hook will return the whole state of the store.

const state = useCounter();

See that we called the useCounter hook with no callback function, so in this case, the function will return the whole state in the store.

The state holds the whole state in the useCounter store. We can get the counter state by doing this:

state.counter;
// 0

We can also, call the incrCounter state function:

state.counter;
// 0

state.incrCounter();
// 1

Memoization We can memoize our zustand store. Memoization is an optimization technique used to optimize the execution of functions by caching the results of expensive or time-consuming function calls. It involves storing the return value of a function associated with a specific set of input parameters so that if the function is called again with the same parameters, the cached result can be returned instead of re-evaluating the function. The goal of memoization is to improve performance and efficiency by avoiding redundant computations.

Now, zustand gives us the ability to add memoization to the custom hook it returns to us. It exports a shallow function that we can use to add memoization to our state picks.

import { shallow } from "zustand/shallow";

Still using our useCounter as an example, let's say we want to get the counter state from the store. We do this:

// DisplayComponent
const counter = useCounter((state) => state.counter);

Now, let's say the initial state of the counter is 0, when the counter state is updated using the incrCounter, the DisplayComponent will be re-rendered. Now, if the updated value of the counter is 0 we will see that it is unnecessary to re-render the DisplayCounter component.

How do we stop this unnecessary re-rendering from occurring when the previous state and the next state are equal? Zustand directs us to pass a comparator function as a second parameter to the custom hook. This comparator function will compare the previous slice state and the next slice state, if both are the same the component will not re-render, else the component will re-render.

This is exactly what other React hooks do: useEffect, useMemo, and useCallback. The shallow function is a comparator function provided to us by Zustand. It shallowly compares the two-state slices using the == shallow equality operator.

const counter = useCounter((state) => state.counter, shallow);

See we passed the shallow function as a second parameter to the useCounter hook. On each state change in the store, the shallow will determine if the component will re-render based on the previous and next value of the counter state. We can use our custom-made comparator if we don't trust the shallow function to do the job. The comparator function takes two parameters, the first parameter is the previous value of the state slice while the second parameter is the next value of the state slice.

(previousState, nextStateSlice) =>

Inside this function is where we do our comparing and return the result. Returning true will make the hook skip the component from re-rendering while returning false will make the component re-render.

Let's create our comparator function for the counter state slice.

(previousCounter, nextCounter) => previousCounter === nextCounter;

This uses the === equality operator to check if the two are the same. It returns a Boolean.

Let's plug it back into the useCounter hook:

const counter = useCounter(
(state) => state.counter,
(previousCounter, nextCounter) => previousCounter === nextCounter,
);

Now, we have memoized our useCounter hook. With this, we have made our application a bit faster, with no more unnecessary re-renders.

Updating the whole state We have only talked about getting the state from the store, but we have not delved into how to set the state. We only saw it briefly when we created the useCounter hook earlier on. Now, we will see how to update the state.

We learned that zustand passes a function to the callback function passed to the create function. This function widely accepted to be referred to as set is used to update all or parts of the state.

Let's look into the incrCounter state function:

const useCounter = create((set) => {
return {
counter: 0,
incrCounter: () => set((state) => ({ counter: state.counter + 1 })),
};
});

Here, we are passing a callback function to the set function. The set function will call this function and pass it the state as an argument, then use the result of the function to update the state. See that in the callback function, we are returning an object with a counter property. The set function uses the properties found in the object to know the properties in the state to update.

We see that when we pass a function to the set function as an argument, the set function expects that we return an object.

We can pass an object to the set function:

set({
counter: 9,
});

This will update the counter state value to 9.

Clear the entire state We can clear the state in a zustand store by passing an empty object to the set function.

set({}, true);

This will clear the state and the actions.

What are actions? Actions are functions that are part of the state in a zustand store. They are like dispatch actions in React-Redux, they are used to effect changes in the store. For example, our incrCounter is an action, we call the set function inside it to update the counter state.

Using async actions Actions in zustand also support asynchronicity. In fact, according to Zustand docs, zustand does not care if your action is asynchronous or not. We can perform an asynchronous function in an action. For example, we can make an HTTP request to an endpoint from our action and update the state with the result from the HTTP call.

Let's show an example:

const useCounter = create((set) => {
return {
counter: 0,
incrCounter: async () => {
const { data } = await axios.get("/counter");
set({
counter: data.counter,
});
},
};
});

See that in the incrCounter we made it an asynchronous function by using the async keyword. Inside the function, we made a call to an /counter endpoint and use the set function to update the value of the counter in the state.

Build a To-do app using Zustand

Now, we have learned the basics of zustand and its API. We will create a To-do app using Zustand.

The To-do app will be a React app, while the Zustand will power our state management.

Let's start, we will scaffold a React project using the create-react-app tool:

create-react-app todo-app
cd todo-app

Next, we install zustand:

npm install zustand

The first thing we create our hook store:

import create from "zustand";

const useStore = create((set) => ({
todos: [],
addTodo: (text) =>
set((state) => ({
todos: [
...state.todos,
{
id: Date.now(),
text,
completed: false,
},
],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo,
),
})),
deleteTodo: (id) =>
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
})),
}));

export default useStore;

See, we have a todos state. This will hold an array of our todos. We have three actions: addTodo, toggleTodo, and deleteTodo. The addTodo action adds new todo to the todos state. , the toggleTodo action toggles the completed state of a todo. The deleteTodo removes a todo from the array state. Now, let's build the components.

DisplayTodos This component will have one job. It will render the todos state:

const DisplayTodos = () => {
const { todos, deleteTodo } = useStore((state) => {
return { todos: state.todos, deleteTodo: state.deleteTodo };
});

return (
<ul>
{todos.map((todo) => (
<li
key={todo.id}
style={{
textDecoration: todo.completed ? "line-through" : "none",
}}
onClick={() => toggleTodo(todo.id)}
>
{todo.text}
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
);
};

export default DisplayTodos;

We sliced off the todos array from the state and used the Array#map method to render the todos, also we sliced off the deleteAction. The Delete button removes each todo from the list, it does this by calling the deleteAction action with the id of the todo clicked. Now, let's build the component where we can add a todo to the list.

TodosControl

const TodosControl = () => {
const addTodo = useStore((state) => state.addTodo);
const [text, setText] = useState("");

function handleSubmit(e) {
e.preventDefault();
addTodo(text);
setText("");
}

return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type="submit">Add</button>
</form>
);
};

export default TodosControl;

This component provides us a form where we input a todo and add it to the state store. We have a state text that holds the text we type in the input element. Then, the handleSubmit function is called when the form is submitted via the Add button. Inside the handleSubmit function, the addTodo is called passing in the text in the text state as an argument. This will create and add a new todo to the todos state.

Bringing them all together:

const App = () => {
return (
<>
<DisplayTodos />
<TodosControl />
</>
);
};

export default App;

Managing State Structures

I was very eager to present advanced patterns of state management with Zustand, which I've come to investigate recently. These will help us deal with more complex state structures and improve application performance and maintainability.

Zustand helps us create nested state slices, so handling complex state structures is straightforward. Structuring the state in a more modular way makes it easier to update and manage some specific bits of state without impacting others.

Here is an example:

const useStore = create((set) => ({
user: {
name: "",
age: 0,
address: {
street: "",
city: "",
},
},
updateUser: (newUser) =>
set((state) => ({ user: { ...state.user, ...newUser } })),
updateAddress: (newAddress) =>
set((state) => ({
user: {
...state.user,
address: { ...state.user.address, ...newAddress },
},
})),
}));

In this case, we had a nested state for the user and address, and therefore separate methods to update the fields of the user and address, respectively.

Use Middleware with Zustand

Zustand can be enhanced with middleware to provide additional functionality, such as changes in state logging, the ability to save and load states in local storage, and handling asynchronous actions.

Below is how we may use middleware to log state changes:

import { create } from "zustand";
import { devtools } from "zustand/middleware";

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

Wrapping our store definition with the devtools configuration now allows state changes to be observable in the Redux DevTools extension, which is a great tool for debugging.

Writing Custom Hooks

We'll see how to create custom hooks and utilities that would make state management easy with Zustand. They help in the reuse of logic for cleaner code in our React projects.

Custom hooks can encapsulate Zustand state logic, making it a lot easier to reuse and manage complex state interactions. Here's an example of how one can do that for managing user authentication state:

import { create } from "zustand";

const useAuthStore = create((set) => ({
user: null,
login: (userData) => set({ user: userData }),
logout: () => set({ user: null }),
}));

const useAuth = () => {
const { user, login, logout } = useAuthStore();
return { user, login, logout };
};

export default useAuth;

With this custom hook, you may use it to manage the authentication state in any component by just calling useAuth.

Constructing Utility Functions

Utility functions can also be used to simplify the state update and make code readability much easier. Below, take a look at an example with an updated nested state:

const updateNestedState = (set, keyPath, value) => {
set((state) => {
const keys = keyPath.split(".");
let nestedState = state;
keys.slice(0, -1).forEach((key) => {
nestedState = nestedState[key];
});
nestedState[keys[keys.length - 1]] = value;
return { ...state };
});
};

const useNestedStateStore = create((set) => ({
data: {
user: {
profile: {
name: "",
},
},
},
updateProfileName: (name) =>
updateNestedState(set, "data.user.profile.name", name),
}));

That makes our task easier in updating state properties that are deeply nested and allows the use of the utility function in various parts of our application.

Conclusion

We have come a long way. Zustand is interesting and freakingly minimal and basic to use. Mind you, what we learned here is just the basics, the power of Zustand is yet to be covered in total and it runs deep.

Let's recap. We started by introducing Zustand; how it works and how it differentiates itself from the world-popular Redux. Next, we saw how to install the Zustand library, we learned also how to set up a state in it and how to use the actions. We learned how to set up async operations in actions, how to update a state, and how to get slices off a state.