Global state in a few lines with no provider, no boilerplate, and re-renders only where the state is actually used.

Bishal Biswas
Self Learner
The whole store
npm install zustand
import { create } from 'zustand';
export const useCartStore = create((set) => ({
items: [],
addItem: (product) =>
set((state) => ({ items: [...state.items, product] })),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
clear: () => set({ items: [] })
}));
That is the entire setup. No provider, no reducer, no action types, no context.
Using it
function Cart() {
const items = useCartStore((state) => state.items);
const removeItem = useCartStore((state) => state.removeItem);
return (
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name}
<button onClick={() => removeItem(item.id)}>Remove</button>
</li>
))}
</ul>
);
}
The store is a hook. Call it with a selector and you get that value, plus a subscription to changes in it.
No provider
function App() {
return <Shop />;
}
The store lives in a module, not in the React tree. Any component can use it from anywhere, including code outside React:
useCartStore.getState().addItem(product);
useCartStore.subscribe((state) => console.log(state.items.length));
That is genuinely useful in event handlers, WebSocket callbacks and tests.
Selectors matter
const store = useCartStore();
const items = useCartStore((state) => state.items);
The first subscribes to everything, so any change anywhere re-renders. The second re-renders only when items changes.
Selecting several values needs care:
const { items, total } = useCartStore((state) => ({
items: state.items,
total: state.total
}));
That builds a new object each time, so it always looks different. Two options:
const items = useCartStore((state) => state.items);
const total = useCartStore((state) => state.total);
import { useShallow } from 'zustand/react/shallow';
const { items, total } = useCartStore(
useShallow((state) => ({ items: state.items, total: state.total }))
);
Separate selectors are usually clearer.
Updates merge shallowly
set({ items: [] });
set((state) => ({ items: [...state.items, item] }));
set merges at the top level, so you only pass what changes. Nested objects still need spreading:
set((state) => ({
user: { ...state.user, name: 'Asha' }
}));
For deep updates, add the Immer middleware and mutate:
import { immer } from 'zustand/middleware/immer';
export const useStore = create(
immer((set) => ({
user: { profile: { name: '' } },
setName: (name) =>
set((state) => {
state.user.profile.name = name;
})
}))
);
Async is just async
No thunks, no middleware, no special API.
export const useUserStore = create((set, get) => ({
user: null,
isLoading: false,
error: null,
fetchUser: async (id) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('Failed');
set({ user: await response.json(), isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
refresh: async () => {
const current = get().user;
if (current) await get().fetchUser(current.id);
}
}));
get() reads the current state inside an action, which is how refresh reuses fetchUser.
Persisting
import { persist, createJSONStorage } from 'zustand/middleware';
export const useSettingsStore = create(
persist(
(set) => ({
theme: 'light',
toggleTheme: () =>
set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' }))
}),
{
name: 'settings',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ theme: state.theme })
}
)
);
partialize chooses what to save, so transient values are not written to storage.
For server rendering, note this causes a hydration mismatch, since the server has no localStorage. Gate on a mounted flag or use skipHydration.
Slicing a big store
const createCartSlice = (set) => ({
items: [],
addItem: (p) => set((state) => ({ items: [...state.items, p] }))
});
const createUserSlice = (set) => ({
user: null,
setUser: (user) => set({ user })
});
export const useStore = create((...args) => ({
...createCartSlice(...args),
...createUserSlice(...args)
}));
Keeps one store while splitting the code by feature.
Zustand or the alternatives?
Zustand for global client state with minimal ceremony. Small, no provider, easy to learn in an afternoon.
Context when the value rarely changes and you want no dependency at all. Remember every consumer re-renders on a change, which Zustand’s selectors avoid.
Redux Toolkit when you need DevTools time travel, a strict action log, or the team already uses it.
TanStack Query for server data. Do not put fetched data in Zustand and reimplement caching.
That last point is the one people get wrong. Zustand is for client state: the open modal, the theme, the cart before checkout, the wizard step.
Quick recap
- A store is created with
createand used as a hook. - No provider, so it works anywhere including outside React.
- Always pass a selector, or you subscribe to the whole store.
- Selectors returning new objects need
useShallowor splitting. setmerges shallowly, and Immer middleware handles deep updates.- Async actions are plain async functions, with
get()for current state. persistsaves to storage, withpartializechoosing what.- Use it for client state, not for server data.
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { useShallow } from 'zustand/react/shallow';
// The ENTIRE store. No provider, no reducer, no action types.
export const useCartStore = create((set, get) => ({
items: [],
isOpen: false,
addItem: (product) =>
set((state) => ({ items: [...state.items, product] })),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
clear: () => set({ items: [] }), // set merges shallowly
// Async is just async. No thunks, no middleware.
checkout: async () => {
const items = get().items; // get() reads current state
await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ items })
});
set({ items: [] });
}
}));
function Cart() {
// ALWAYS pass a selector. useCartStore() with no argument subscribes
// to the whole store, so any change anywhere re-renders this.
const items = useCartStore((state) => state.items);
const removeItem = useCartStore((state) => state.removeItem);
// Selecting several values: a plain object literal is new every time,
// so it always looks changed. Use useShallow, or separate selectors.
const { isOpen, clear } = useCartStore(
useShallow((state) => ({ isOpen: state.isOpen, clear: state.clear }))
);
return (
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name}
<button onClick={() => removeItem(item.id)}>Remove</button>
</li>
))}
</ul>
);
}
// The store lives in a module, so it works OUTSIDE React too
// useCartStore.getState().addItem(product);
// useCartStore.subscribe((state) => console.log(state.items.length));
// persist saves to storage, and partialize picks what is worth saving
export const useSettingsStore = create(
persist(
(set) => ({
theme: 'light',
draft: '', // transient, not persisted
toggleTheme: () =>
set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' }))
}),
{
name: 'settings',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ theme: state.theme })
}
)
);
// USE IT FOR CLIENT STATE: the open modal, the theme, the cart, the
// wizard step. For server data use TanStack Query, rather than
// reimplementing caching and revalidation in a store.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready