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?
- Getting started with Zustand
- Build a To-do app using Zustand
- Managing State Structures
- Writing Custom Hooks
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 runningnpm 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 "zucd 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 astore
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.