On this page
Global State with Zustand: Simple and Scalable Stores
Why Zustand?
While the Context API works well for low-frequency global values (theme, locale, auth), it has performance limitations for frequently-updated shared state: every consumer re-renders whenever any part of the context changes.
Zustand is a minimal, fast, and scalable state management library that solves this:
- No Provider boilerplate
- Subscription to slices of state (surgical re-renders)
- Built-in middleware for devtools, persistence, and immer
- Compatible with React Concurrent Mode
- Works outside of React components (in services, utilities)
- Tiny bundle: ~1kB gzipped
Installation
npm install zustandCreating a Store
A Zustand store is created with the create function. The store holds both state and actions:
import { create } from 'zustand';
interface CounterStore {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
incrementBy: (amount: number) => void;
}
const useCounterStore = create<CounterStore>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
}));
export { useCounterStore };The set function accepts either a partial state object or a function that receives the current state and returns a partial state object.
Using the Store in Components
function CounterDisplay() {
// Only re-renders when `count` changes
const count = useCounterStore((s) => s.count);
return <p>Count: {count}</p>;
}
function CounterControls() {
// Actions have stable references — this component rarely re-renders
const increment = useCounterStore((s) => s.increment);
const decrement = useCounterStore((s) => s.decrement);
const reset = useCounterStore((s) => s.reset);
return (
<div>
<button type="button" onClick={decrement}>−</button>
<button type="button" onClick={reset}>Reset</button>
<button type="button" onClick={increment}>+</button>
</div>
);
}Note that CounterDisplay and CounterControls are separate components. Each subscribes only to what it needs.
Async Actions
Zustand actions can be async — just use async/await inside the action:
interface UserStore {
user: User | null;
isLoading: boolean;
error: string | null;
fetchUser: (id: string) => Promise<void>;
clearUser: () => void;
}
const useUserStore = create<UserStore>()((set) => ({
user: null,
isLoading: false,
error: null,
fetchUser: async (id) => {
set({ isLoading: true, error: null });
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const user = await res.json() as User;
set({ user, isLoading: false });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
set({ error: message, isLoading: false });
}
},
clearUser: () => set({ user: null, error: null }),
}));Middleware: persist
The persist middleware automatically saves and restores store state to/from localStorage:
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface SettingsStore {
theme: 'light' | 'dark';
language: string;
notifications: boolean;
setTheme: (theme: 'light' | 'dark') => void;
setLanguage: (lang: string) => void;
}
const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
theme: 'light',
language: 'en',
notifications: true,
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
}),
{
name: 'app-settings', // localStorage key
storage: createJSONStorage(() => localStorage),
// Only persist specific fields
partialize: (state) => ({
theme: state.theme,
language: state.language,
}),
}
)
);Middleware: devtools
The devtools middleware integrates with Redux DevTools browser extension for time-travel debugging:
import { devtools } from 'zustand/middleware';
const useProductStore = create<ProductStore>()(
devtools(
(set) => ({
products: [],
selectedId: null,
setProducts: (products) => set({ products }, false, 'setProducts'),
selectProduct: (id) => set({ selectedId: id }, false, 'selectProduct'),
}),
{ name: 'ProductStore' }
)
);Passing action names as the third argument to set makes debugging much easier in the DevTools.
Slices Pattern
For large stores, organize state into slices and combine them:
import { create, type StateCreator } from 'zustand';
interface AuthSlice {
user: User | null;
token: string | null;
setUser: (user: User, token: string) => void;
logout: () => void;
}
interface UISlice {
sidebarOpen: boolean;
toggleSidebar: () => void;
notification: string | null;
showNotification: (msg: string) => void;
}
type AppStore = AuthSlice & UISlice;
const createAuthSlice: StateCreator<AppStore, [], [], AuthSlice> = (set) => ({
user: null,
token: null,
setUser: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
});
const createUISlice: StateCreator<AppStore, [], [], UISlice> = (set) => ({
sidebarOpen: false,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
notification: null,
showNotification: (msg) => set({ notification: msg }),
});
const useAppStore = create<AppStore>()((...args) => ({
...createAuthSlice(...args),
...createUISlice(...args),
}));Reading Store State Outside React
Zustand stores can be read and updated outside React components — useful in service layers, utilities, and event handlers:
// Access state imperatively
const currentUser = useUserStore.getState().user;
// Update state imperatively
useUserStore.getState().clearUser();
// Subscribe to state changes outside React
const unsubscribe = useUserStore.subscribe(
(state) => state.user,
(user) => {
if (!user) {
// User logged out — redirect
window.location.href = '/login';
}
}
);
// Cleanup
unsubscribe();Combining Zustand with TanStack Query
Zustand is designed for client state (UI state, user preferences, temporary selections). TanStack Query handles server state (data from APIs). They complement each other perfectly:
function ProductBrowser() {
// Server state — managed by TanStack Query
const { data: products } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
// Client state — managed by Zustand
const selectedId = useProductStore((s) => s.selectedId);
const selectProduct = useProductStore((s) => s.selectProduct);
const selectedProduct = products?.find((p) => p.id === selectedId);
return (
<div className="product-browser">
<ProductList
products={products ?? []}
selectedId={selectedId}
onSelect={selectProduct}
/>
{selectedProduct && <ProductDetail product={selectedProduct} />}
</div>
);
}In the next lesson, you will learn advanced performance techniques including the React Compiler, code splitting, and profiling with DevTools.
import { create } from 'zustand';
import { persist, devtools, subscribeWithSelector } from 'zustand/middleware';
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
isOpen: boolean;
// Actions
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: number) => void;
updateQuantity: (id: number, quantity: number) => void;
clearCart: () => void;
toggleCart: () => void;
// Derived (computed in selectors)
}
export const useCartStore = create<CartStore>()(
devtools(
persist(
subscribeWithSelector((set) => ({
items: [],
isOpen: false,
addItem: (newItem) =>
set((state) => {
const existing = state.items.find((i) => i.id === newItem.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === newItem.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return { items: [...state.items, { ...newItem, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
items:
quantity <= 0
? state.items.filter((i) => i.id !== id)
: state.items.map((i) =>
i.id === id ? { ...i, quantity } : i
),
})),
clearCart: () => set({ items: [] }),
toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
})),
{
name: 'cart-storage',
partialize: (state) => ({ items: state.items }),
}
)
)
);
// Selectors (defined outside store for stable references)
export const selectItemCount = (state: CartStore) =>
state.items.reduce((sum, item) => sum + item.quantity, 0);
export const selectTotal = (state: CartStore) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
Sign in to track your progress