Zustand is a minimal, but fun and effective state management library. It’s somewhat weird for me to write an introductory blog post on a tool that’s over 5 years old and pretty popular. But it’s popular for a reason, and there are almost certainly more developers who aren’t familiar with it than are. So if you’re in the former group, hopefully this post will be the concise and impactful introduction you didn’t know you needed.
The code for everything in this post is on my GitHub repo.
Getting Started
We’ll look at a toy task management app that does minimal work so we can focus on state management. It shows a (static) list of tasks, a button to add a new task, a heading showing the number of tasks, and a component to change the UI view between three options.
Moreover, the same app was written 3 times, once using vanilla React context for state, once using Zustand simply but non-idiomatically, and then a third version using Zustand more properly, so we can see some of the performance benefits it offers.

Each of the three apps is identical, except for the label above the Add New Task button.
Each app is broken down more or less identically as so.
function App() {
console.log("Rendering App");
return (
<div className="m-5 p-5 flex flex-col gap-2">
<VanillaLabel />
<AddNewTask />
<TasksCount />
<TasksHeader />
<Filter />
<TasksBody />
</div>
);
}
Code language: JavaScript (javascript)
It’s probably more components than needed, but it’ll help us inspect render performance.
The state we need
Our state payload for this app will include an array of tasks, a method to update the tasks, the current UI view being displayed, a function to update it, and a current filter, with, of course, a method to update it.
Those values can all be declared as various pieces of state, and then passed down the component tree as needed. This is simple and it works, but the excessive amount of prop passing, often referred to as “prop drilling,” can get annoying pretty quickly. There are many ways to avoid this, from state management libraries like Zustand, Redux, and MobX, to the regular old React context.
In this post, we’ll first explore what this looks like using React context, and then we’ll examine how Zustand can simplify things while improving performance in the process.
The Vanilla Version
There’s a very good argument to be made that React’s context feature was not designed to be a state management library, but that hasn’t stopped many devs from trying. To avoid excessive prop drilling while minimizing external dependencies, developers will often store the state required for a specific part of their UI in context and access it lower in the component tree as needed.
Our app has its entire state stored like this, but that’s just a product of how unrealistically small it is.
Let’s get started. First, we have to declare our context
const TasksContext = createContext<TasksState>(null as any);
Code language: TypeScript (typescript)
Then we need a component that renders a Provider for that context, while declaring, and then passing in the actual state
export const TasksProvider = ({ children }: { children: ReactNode }) => {
console.log("Rendering TasksProvider");
const [tasks, setTasks] = useState<Task[]>(dummyTasks);
const [currentView, setCurrentView] = useState<TasksView>("list");
const [currentFilter, setCurrentFilter] = useState<string>("");
const value: TasksState = {
tasks,
setTasks,
currentView,
setCurrentView,
currentFilter,
setCurrentFilter,
};
return <TasksContext.Provider value={value}>{children}</TasksContext.Provider>;
};
Code language: TypeScript (typescript)
The logging console.log("Rendering TasksProvider");
is present in every component in all versions of this app, so we can inspect re-renders.
Notice how we have to declare each piece of state with useState
(or useReducer
)
const [tasks, setTasks] = useState<Task[]>(dummyTasks);
const [currentView, setCurrentView] = useState<TasksView>("list");
const [currentFilter, setCurrentFilter] = useState<string>("");
Code language: TypeScript (typescript)
and then splice it together in our big state payload, and then render our context provider
const value: TasksState = {
tasks,
setTasks,
currentView,
setCurrentView,
currentFilter,
setCurrentFilter,
};
return <TasksContext.Provider value={value}>{children}</TasksContext.Provider>;
Code language: TypeScript (typescript)
To get the current context value in a component that wants to use it, we call the useContext
hook, and pass in the context object we declared above. To simplify this, it’s not uncommon to build a simple hook for just this purpose.
export const useTasksContext = () => {
return useContext(TasksContext);
};
Code language: TypeScript (typescript)
Now components can grab whatever slice of state they need.
const { currentView, tasks, currentFilter } = useTasksContext();
Code language: TypeScript (typescript)
What’s the problem?
This code is fine. It’s simple enough. And it works. I’ll be honest, though, as someone who works with code like this a lot, the boilerplate can become annoying pretty quickly. We have to declare each piece of state with the normal React primitives (useState, useReducer), and then also integrate it into our context payload (and typings). It’s not the worst thing to deal with; it’s just annoying.
Another downside of this code is that all consumers of this context will always rerender anytime any part of the context changes, even if that particular component is not using the part of the context that just changed. We can see that with the logging that’s in these components.
For example, changing the current UI view rerenders everything, even though only the task header, and task body read that state

Introducing Zustand
Zustand is a minimal but powerful state management library. To create state, Zustand gives you a create
method
import { create } from "zustand";
Code language: JavaScript (javascript)
It’s easier to show this than to describe it.
export const useTasksStore = create<TasksState>(set => ({
tasks,
setTasks: (arg: Task[] | ((tasks: Task[]) => Task[])) => {
set(state => {
return {
tasks: typeof arg === "function" ? arg(state.tasks) : arg,
};
});
},
currentView: "list",
setCurrentView: (newView: TasksView) => set({ currentView: newView }),
currentFilter: "",
setCurrentFilter: (newFilter: string) => set({ currentFilter: newFilter }),
}));
Code language: TypeScript (typescript)
We pass a function to create
and return our state. Just like that. Simple and humble. The function we pass also takes an argument, which I’ve called set
. The result of the create
function, which I’ve named useTasksStore
here, will be a React hook that you use to read your state.
Updating state
Updating our state couldn’t be simpler. The set
function we see above is how we do that. Notice our updating functions like this:
setCurrentView: (newView: TasksView) => set({ currentView: newView }),
Code language: TypeScript (typescript)
By default set
will take what we return, and integrate it into the state that’s already there. So we can return the pieces that have changed, and Zustand will handle the update.
Naturally, there’s an override: if we pass true
for the second argument to set
, then what we return will overwrite the existing state in its entirety.
clear: () => set({}, true);
Code language: JavaScript (javascript)
The above would wipe our state, and replace it with an empty object; use this cautiously!
Reading our state
To read our state in the components which need it, we call the hook that was returned from create
, which would be useTasksStore
from above. We could read our state in the same way we read our context above
This is not the best way to use Zustand. Keep reading for a better way to use this API.
const { currentView, tasks, currentFilter } = useTasksStore();
Code language: JavaScript (javascript)
This will work and behave exactly like our context example before.
This means changing the current UI view will again re-render all components that read anything from the Zustand store, whether related to this piece of state, or not.

The Correct Way to Read State
It’s easy to miss in the docs the first time you read them, but when reading from your Zustand store, you shouldn’t do this:
const { yourFields } = useTasksStore();
Code language: JavaScript (javascript)
Zustand is well optimized, and will cause the component with the call to useTasksStore
to only re-render when the result of the hook call changes. By default, it returns an object with your entire state. And when you change any piece of your state, the surrounding object will have to be recreated by Zustand, and will no longer match.
Instead, you should pass a selector argument into useTasksStore
, in order to select the piece of state you want. The simplest usage would look like this
const currentView = useTasksStore(state => state.currentView);
const tasks = useTasksStore(state => state.tasks);
const currentFilter = useTasksStore(state => state.currentFilter);
Code language: TypeScript (typescript)
Now our call returns only the currentView
value in the first line, or our tasks
array, or currentFilter
in our second and third lines, respectively.
The value returned for currentView
will only be different if you’ve changed that state value, and so on with tasks
, and currentFilter
. That means if none of these values have changed, then this component will not rerender, even if other values in our Zustand store have changed.
If you don’t like having those multiple calls, you’re free to use Zustand’s useShallow
helper
import { useShallow } from "zustand/react/shallow";
// ...
const { tasks, setTasks } = useTasksStore(
useShallow(state => ({
tasks: state.tasks,
setTasks: state.setTasks,
}))
);
Code language: JavaScript (javascript)
The useShallow
hook lets us return an object with the state we want, and will trigger a rerender only if a shallow check on the properties in this object change.
If you want to save a few lines of code, you’re also free to return an array with useShallow
.
const [tasks, setTasks] = useTasksStore(useShallow(state => [state.tasks, state.setTasks]));
Code language: JavaScript (javascript)
This does the same thing.
The Zustand-optimized version of the app only uses the useTasksStore
hook with a selector function, which means we can observe our improved re-rendering.
Changing the current UI view will only rerender the components that use the ui view part of the state.

For a trivial app like this, it probably won’t matter, but for a large app at scale, this can be beneficial, especially for users on slower devices.
Odds & Ends
The full Zustand docs are here. Zustand has a delightfully small surface area, so I’d urge you to just read the docs if you’re curious.
That being said, there are a few features worth noting here.
Async friendly
Zustand doesn’t care where or when the set
function is called. You’re free to have async methods in your store, which call set
after a fetch.
The docs offer this example:
const useFishStore = create(set => ({
fishies: {},
fetch: async pond => {
const response = await fetch(pond);
set({ fishies: await response.json() });
},
}));
Code language: JavaScript (javascript)
set
Reading state inside your store, but outside of We already know that we can call set(oldState => newState)
, but what if we need (or just want) to read the current state inside one of our actions, unrelated to an update?
It turns out create
also has a second argument, get
, that you can use for this very purpose
export const useTasksStore = create<TasksState>((set, get) => ({
Code language: TypeScript (typescript)
And now you can do something like this
logOddTasks: () => {
const oddTasks = get().tasks.filter((_, index) => index % 2 === 0);
console.log({ oddTasks: oddTasks });
},
Code language: JavaScript (javascript)
The first line grabs a piece of state, completely detached from any updates.
Reading state outside of React components
Zustand gives you back a React hook from create
. But what if you want to read your state outside of a React component? Zustand attaches a getState()
method directly onto your hook, which you can call anywhere.
useEffect(() => {
setTimeout(() => {
console.log("Can't call a hook here");
const tasks = useTasksStore.getState().tasks;
console.log({ tasks });
}, 1000);
}, []);
Code language: JavaScript (javascript)
Pushing further
Zustand also supports manual, fine-grained subscriptions; bindings for vanilla JavaScript, with no React at all; and integrates well with immutable helpers like Immer. It also has some other, more advanced goodies that we won’t try to cover here. Check out the docs if this post has sparked your interest!
Concluding Thoughts
Zustand is a wonderfully simple, frankly fun library to use to manage state management in React. And as an added bonus, it can also improve your render performance.
My god that syntax has reacted the point of insanity. The cure looks just as bad as the desease syntactically.
Great post Adam!
In my day job, I’m using Redux, but it’s nice to see how other state management libraries do things, and I can see why Zustand is as popular as it is.
Granted, a lot of the apps I’m working on are legacy, and it might be overkill to introduce Zustand, but hey, I might just do it for shiggles. 😆
I’m interested to see what a vanilla solutions looks and preforms like without context. One thing that is good about “prop drilling” is that it makes dependencies very clear. Each component that needs some state has to either own it via
useState()
/useReducer()
or get it passed into it from its parent. Creating tests and stories (e.g. Storybook) is now simpler (no context provider wrapping is needed nor any module mocking). I suspect that the simple prop drilling approach would lead to easier to read code (due to clear dependencies), more testable code, and code that performances just as well, maybe even better, than the Zustand-optimized version.