What is Zustand?
Zustand is a small, fast state store for React. Its whole API fits in your head: you call create with a function that returns state and actions, and you get back a hook. There is no provider, no reducer, no action types and no dispatch. You import the hook and use it.
The name is German for “state”, and the library lives up to the simplicity. It is around a kilobyte, uses selector-based subscriptions to keep re-renders minimal, and works with plain JavaScript and TypeScript. For many React apps it is the least ceremony you can add and still share state globally.
Creating a store
A store is a hook. State and the functions that update it live together.
// store.js
import { create } from "zustand";
export const useStore = create((set, get) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
double: () => get().count * 2,
}));
set merges the returned object into the state, and get reads the current state imperatively. There is no reducer to write and no action to dispatch — just functions that update state.
Reading state with selectors
Components subscribe to the exact slice they need.
// Counter.jsx
import { useStore } from "./store";
export function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
Because the component subscribes to state.count only, updates to unrelated parts of the store do not re-render it. That is the key performance difference from React’s Context API, which re-renders every consumer when the value changes.
For selecting multiple values, use a shallow comparison so the component re-renders only when one of them actually changes:
// shallow.js
import { useShallow } from "zustand/react/shallow";
const { count, increment } = useStore(
useShallow((state) => ({ count: state.count, increment: state.increment })),
);
Updating state
set merges by default, so you only specify what changed. Pass a function when the next value depends on the previous one.
// updates.js
set({ count: 0 }); // merge
set((state) => ({ count: state.count + 1 })); // derive from previous
set((state) => ({ items: [...state.items, item] })); // arrays
For nested objects, set replaces the top-level key, so spread the existing object when you need to keep other properties. The Immer middleware is available if you prefer mutating syntax for deep updates.
Async actions
Async logic is just an async function in the store. There is no middleware to configure.
// usersStore.js
import { create } from "zustand";
export const useUsers = create((set) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const res = await fetch("/api/users");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
set({ users: await res.json(), loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
}));
The loading and error flags live right next to the data, and components select only the pieces they display.
Slices and middleware
For a large store, split it into slices that are combined into one store. Each slice is a function that receives set and get.
// slices/cart.js
export const createCartSlice = (set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
clear: () => set({ items: [] }),
});
// store.js
import { create } from "zustand";
import { createCartSlice } from "./slices/cart";
import { createUserSlice } from "./slices/user";
export const useStore = create((...args) => ({
...createCartSlice(...args),
...createUserSlice(...args),
}));
Middleware composes like functions around the store creator. The most common are:
- persist saves and rehydrates state in
localStorageor another storage. - devtools connects the store to the Redux DevTools extension.
- immer lets you write mutating updates that stay immutable.
// persisted.js
import { persist } from "zustand/middleware";
export const useSettings = create(
persist(
(set) => ({
theme: "dark",
setTheme: (theme) => set({ theme }),
}),
{ name: "settings" },
),
);
When to use Zustand
Zustand is a great fit for global client state: theme and preferences, a shopping cart, UI state shared across distant components, drafts, filters and modal state. It is especially nice when Context would cause too many re-renders or when Redux feels like more structure than you need.
Do not use it for server state. Data that comes from an API needs caching, deduplication, background refetching and invalidation, and TanStack Query handles those concerns far better. The common setup is Zustand for client state and TanStack Query for server state.
Best practices
- Always read state with a selector.
- Use
useShallowwhen selecting multiple values. - Keep actions in the store so components do not know the state shape.
- Split large stores into slices.
- Use the persist middleware for settings that should survive reloads.
- Keep server data out of the store.
- Store plain, serialisable data where possible.
Common mistakes
- Calling
useStore()with no selector and re-rendering on every change. - Returning a new object from a selector without a shallow comparison.
- Treating Zustand as a server-state cache.
- Mutating nested state without Immer and losing updates.
- Creating a store inside a component, which resets it on every render.
- Adding Redux-style ceremony that Zustand was designed to remove.
Where to go next
Zustand is often the sweet spot between Context and Redux. Compare it with Context for simple sharing and Redux Toolkit for strict, tooled-up state, and move server data to TanStack Query. For Vue, the same role is played by Pinia.