Zustand
Zustand (German for “state”) is a tiny, hook-based state manager that gives you global state without providers, reducers, action types, or boilerplate. You define a store as a single hook, read exactly the slice you need with a selector, and mutate it by calling plain functions. It is unopinionated, fast, and around 1 KB gzipped — which is why it has become the go-to lightweight alternative to Redux for many React apps.
Why Zustand is popular
Three things set Zustand apart. First, there is no <Provider> — the store lives in module scope, so any component (or even non-React code) can import and use it. Second, selectors keep re-renders surgical: a component only re-renders when the specific value it subscribes to changes. Third, the API is just functions — actions are ordinary methods you put inside the store, so there is nothing new to learn beyond create and set.
Installation
Add the single package — it has no peer dependencies beyond React:
npm install zustand
Creating a store
create takes a function that receives set (and optionally get) and returns your initial state plus the actions that update it. The result is a custom hook you call from any component.
// stores/useCounterStore.js
import { create } from 'zustand';
export const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
reset: () => set({ count: 0 }),
}));
set merges the object you return into the existing state (a shallow merge, like this.setState in old class components). Pass it a function when the new value depends on the previous state, or a plain object when it does not.
Subscribing with selectors
Call the hook with a selector that picks out just the piece of state a component needs. Zustand compares the selected value between renders and only re-renders when it actually changes.
// Counter.jsx
import { useCounterStore } from './stores/useCounterStore';
export default function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
const incrementBy = useCounterStore((state) => state.incrementBy);
return (
<section>
<h2>Count: {count}</h2>
<button onClick={increment}>+1</button>
<button onClick={() => incrementBy(5)}>+5</button>
</section>
);
}
A component that selects only state.increment never re-renders when count changes, because the function identity is stable. This is what makes Zustand feel fast by default.
Calling the hook with no selector —
useCounterStore()— returns the entire store object, so the component re-renders on every state change. Always select the narrowest slice you need.
Selecting multiple values
When you need several values at once, return them with useShallow so Zustand compares the fields shallowly instead of by object identity (a fresh object literal would otherwise re-render on every store update).
import { useShallow } from 'zustand/react/shallow';
function Toolbar() {
const { count, reset } = useCounterStore(
useShallow((state) => ({ count: state.count, reset: state.reset }))
);
return <button onClick={reset}>Reset ({count})</button>;
}
Actions and async logic
Because actions are just methods on the store, async work is trivial — await inside the action and call set when the data arrives. Use get to read current state without subscribing.
// stores/useTodosStore.js
import { create } from 'zustand';
export const useTodosStore = create((set, get) => ({
items: [],
loading: false,
fetchTodos: async () => {
set({ loading: true });
const res = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=3');
const items = await res.json();
set({ items, loading: false });
},
toggle: (id) =>
set((state) => ({
items: state.items.map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t
),
})),
remaining: () => get().items.filter((t) => !t.completed).length,
}));
// Todos.jsx
import { useEffect } from 'react';
import { useTodosStore } from './stores/useTodosStore';
export default function Todos() {
const items = useTodosStore((state) => state.items);
const loading = useTodosStore((state) => state.loading);
const fetchTodos = useTodosStore((state) => state.fetchTodos);
const toggle = useTodosStore((state) => state.toggle);
useEffect(() => { fetchTodos(); }, [fetchTodos]);
if (loading) return <p>Loading…</p>;
return (
<ul>
{items.map((t) => (
<li key={t.id} onClick={() => toggle(t.id)}
style={{ textDecoration: t.completed ? 'line-through' : 'none' }}>
{t.title}
</li>
))}
</ul>
);
}
After the fetch resolves and the first todo is toggled, the store holds:
Output:
items: [
{ id: 1, title: "delectus aut autem", completed: true },
{ id: 2, title: "quis ut nam facilis...", completed: false },
{ id: 3, title: "fugiat veniam minus", completed: false }
]
loading: false
Middleware
Zustand ships small middleware that wrap your store creator. The two most common are persist (save to localStorage) and devtools (Redux DevTools integration). They compose by nesting.
// stores/useSettingsStore.js
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
export const useSettingsStore = create(
devtools(
persist(
(set) => ({
theme: 'light',
toggleTheme: () =>
set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
}),
{ name: 'settings' } // localStorage key
)
)
);
| Middleware | Import | Purpose |
|---|---|---|
persist | zustand/middleware | Saves and rehydrates state from localStorage (or any storage). |
devtools | zustand/middleware | Streams actions to the Redux DevTools extension for time-travel. |
immer | zustand/middleware/immer | Lets you write “mutating” updates safely (see Immer). |
subscribeWithSelector | zustand/middleware | Adds fine-grained subscribe(selector, callback) outside React. |
Best Practices
- Always read state through a narrow selector; selecting the whole store defeats Zustand’s render optimization.
- Use
useShallowwhen a selector returns a new object or array of multiple fields. - Keep actions inside the store so all mutation logic is co-located and testable in isolation.
- Use the functional form of
setwhenever the next value depends on the previous state. - Split unrelated concerns into separate stores rather than one giant global object — there is no Provider cost to having many.
- Reach for
persistfor user preferences anddevtoolsduring development; order middleware asdevtools(persist(...)). - Access the store outside React via
useStore.getState()/useStore.setState()for event handlers, tests, or non-component code.