Zustand is a lightweight, fast, and flexible state management solution for React. Let's master it from basics to advanced patterns.
Why Zustand?
- Minimal boilerplate: No actions, reducers, or dispatch
- Simple API: Just hooks and stores
- TypeScript friendly: Built-in types support
- No providers needed: Use stores anywhere
- Tiny bundle: ~1KB minified
- DevTools: Built-in middleware
Installation
bash
npm install zustandBasic Store
Create Store
typescript
// store/useCounterStore.ts
import { create } from 'zustand';
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
incrementBy: (amount: number) => void;
}
export const useCounterStore = create<CounterState>((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 })),
}));Use in Component
typescript
// components/Counter.tsx
import { useCounterStore } from '../store/useCounterStore';
export function Counter() {
const { count, increment, decrement, reset } = useCounterStore();
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
<button onClick={reset}>Reset</button>
</div>
);
}Selecting Specific State
Select Single Value
typescript
// Only re-renders when count changes
const count = useCounterStore((state) => state.count);
// Only re-renders when increment changes
const increment = useCounterStore((state) => state.increment);Select Multiple Values
typescript
// Re-renders when either count or name changes
const { count, name } = useCounterStore(
(state) => ({ count: state.count, name: state.name })
);
// Or use shallow comparison for objects
import { shallow } from 'zustand/shallow';
const { count, name } = useCounterStore(
(state) => ({ count: state.count, name: state.name }),
shallow
);Async Actions
Simple Async
typescript
// store/useUserStore.ts
import { create } from 'zustand';
interface UserState {
user: null | { id: number; name: string; email: string };
loading: boolean;
error: null | string;
fetchUser: (id: number) => Promise<void>;
}
export const useUserStore = create<UserState>((set) => ({
user: null,
loading: false,
error: null,
fetchUser: async (id) => {
set({ loading: true, error: null });
try {
const response = await fetch(`https://api.example.com/users/${id}`);
const user = await response.json();
set({ user, loading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to fetch user',
loading: false,
});
}
},
}));With Loading States
typescript
// components/UserProfile.tsx
import { useEffect } from 'react';
import { useUserStore } from '../store/useUserStore';
export function UserProfile({ userId }: { userId: number }) {
const { user, loading, error, fetchUser } = useUserStore();
useEffect(() => {
fetchUser(userId);
}, [userId, fetchUser]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}Multiple Stores
Separate Stores by Domain
typescript
// store/useAuthStore.ts
import { create } from 'zustand';
export const useAuthStore = create((set) => ({
user: null,
token: null,
login: async (credentials) => {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
const { user, token } = await response.json();
set({ user, token });
},
logout: () => set({ user: null, token: null }),
}));
// store/useCartStore.ts
export const useCartStore = create((set) => ({
items: [],
total: 0,
addItem: (item) => set((state) => ({
items: [...state.items, item],
total: state.total + item.price,
})),
removeItem: (itemId) => set((state) => ({
items: state.items.filter(item => item.id !== itemId),
})),
clear: () => set({ items: [], total: 0 }),
}));Use Multiple Stores
typescript
import { useAuthStore } from '../store/useAuthStore';
import { useCartStore } from '../store/useCartStore';
export function Header() {
const user = useAuthStore((state) => state.user);
const total = useCartStore((state) => state.total);
return (
<header>
<span>Welcome, {user?.name}</span>
<span>Cart: {total}</span>
</header>
);
}Middleware
DevTools
typescript
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
export const useCounterStore = create(
devtools<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
);Persist (LocalStorage)
typescript
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const useAuthStore = create(
persist<AuthState>(
(set) => ({
user: null,
token: null,
setAuth: (user, token) => set({ user, token }),
clearAuth: () => set({ user: null, token: null }),
}),
{
name: 'auth-storage', // unique key
}
)
);
// Usage with partial persist
export const useUserStore = create(
persist<UserState>(
(set) => ({
user: null,
preferences: {},
setUser: (user) => set({ user }),
setPreferences: (preferences) => set({ preferences }),
}),
{
name: 'user-storage',
partialize: (state) =>
Object.fromEntries(
Object.entries(state).filter(([key]) => key === 'preferences')
),
}
)
);Combine Middlewares
typescript
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
export const useStore = create(
devtools(
persist(
(set) => ({
// ...store logic
}),
{ name: 'app-storage' }
)
)
);Advanced Patterns
Store with Actions
typescript
interface BearState {
bears: number;
increasePopulation: () => void;
removeAllBears: () => void;
}
export const useBearStore = create<BearState>((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}));Store with Computed Values
typescript
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface CartState {
items: Array<{ id: number; price: number; quantity: number }>;
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: number) => void;
updateQuantity: (id: number, quantity: number) => void;
clear: () => void;
}
export const useCartStore = create<CartState>()(
devtools(
persist(
(set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((i) =>
i.id === id ? { ...i, quantity } : i
),
})),
clear: () => set({ items: [] }),
}),
{ name: 'cart-storage' }
)
)
);
// Computed selector outside store
export const useCartTotal = () =>
useCartStore((state) =>
state.items.reduce((total, item) => total + item.price * item.quantity, 0)
);Actions with setState
typescript
export const useStore = create((set) => ({
// State
items: [],
filter: 'all',
// Simple actions
setFilter: (filter: string) => set({ filter }),
// Actions that use current state
addItem: (item: Item) =>
set((state) => ({
items: [...state.items, item],
})),
// Complex actions
addItems: (items: Item[]) =>
set((state) => ({
items: [...state.items, ...items],
})),
// Actions that need multiple states
updateItem: (id: number, updates: Partial<Item>) =>
set((state) => ({
items: state.items.map((item) =>
item.id === id ? { ...item, ...updates } : item
),
})),
}));TypeScript Best Practices
Full Type Safety
typescript
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
// Define state and actions separately
interface TodoState {
todos: Array<{
id: number;
title: string;
completed: boolean;
}>;
filter: 'all' | 'active' | 'completed';
}
interface TodoActions {
addTodo: (title: string) => void;
toggleTodo: (id: number) => void;
deleteTodo: (id: number) => void;
setFilter: (filter: TodoState['filter']) => void;
}
type TodoStore = TodoState & TodoActions;
// Type-safe store creation
export const useTodoStore = create<TodoStore>()(
devtools(
persist(
(set, get) => ({
// State
todos: [],
filter: 'all',
// Actions
addTodo: (title) => {
const newTodo = {
id: Date.now(),
title,
completed: false,
};
set((state) => ({
todos: [...state.todos, newTodo],
}));
},
toggleTodo: (id) => {
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
),
}));
},
deleteTodo: (id) => {
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
}));
},
setFilter: (filter) => set({ filter }),
}),
{ name: 'todo-storage' }
)
)
);
// Type-safe selectors
export const useFilteredTodos = () =>
useTodoStore((state) => {
const { todos, filter } = state;
switch (filter) {
case 'active':
return todos.filter((todo) => !todo.completed);
case 'completed':
return todos.filter((todo) => todo.completed);
default:
return todos;
}
});Testing
Test Store
typescript
// tests/store/useCounterStore.test.ts
import { act, renderHook } from '@testing-library/react';
import { useCounterStore } from '../store/useCounterStore';
describe('useCounterStore', () => {
it('should increment count', () => {
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('should increment by amount', () => {
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.incrementBy(5);
});
expect(result.current.count).toBe(5);
});
});Performance Optimization
Prevent Unnecessary Re-renders
typescript
// ❌ BAD: Re-renders on any state change
const { count, name, age } = useStore();
// ✅ GOOD: Only re-renders when count changes
const count = useStore((state) => state.count);
// ✅ GOOD: Re-renders only when count or name changes
const { count, name } = useStore(
(state) => ({ count: state.count, name: state.name }),
shallow
);Use Zustand/shallow for Objects
typescript
import { shallow } from 'zustand/shallow';
// Only re-renders if name or age changes
const { name, age } = useStore(
(state) => ({ name: state.name, age: state.age }),
shallow
);Selector Functions
typescript
// Create reusable selectors
const selectCount = (state: CounterState) => state.count;
const selectIncrement = (state: CounterState) => state.increment;
// Use in components
const count = useCounterStore(selectCount);
const increment = useCounterStore(selectIncrement);Server-Side Rendering
Hydration Pattern
typescript
import { create } from 'zustand';
export const useStore = create(() => ({
// ...store logic
}));
// Hydrate store on client
export const hydrateStore = (initialState: AppState) => {
useStore.setState(initialState);
};
// In SSR setup
export function getServerState() {
return useStore.getState();
}
// In client entry
import { hydrateStore } from './store';
hydrateStore(window.__INITIAL_STATE__);Best Practices
1. Organize Stores by Feature
typescript
// store/
// useAuthStore.ts
// useCartStore.ts
// useUserStore.ts
// useProductStore.ts2. Use TypeScript
Always type your stores:
typescript
interface MyState {
// ...
}
export const useMyStore = create<MyState>((set) => ({
// ...
}));3. Select Specific State
Avoid selecting entire state:
typescript
// ❌ BAD
const state = useStore();
// ✅ GOOD
const value = useStore((state) => state.value);4. Use Middleware
Add DevTools and persist:
typescript
export const useStore = create(
devtools(
persist(
(set) => ({
// ...
}),
{ name: 'my-storage' }
)
)
);5. Keep Stores Simple
Don't over-engineer:
typescript
// ✅ GOOD: Simple and clear
export const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
// ❌ BAD: Over-complicated
export const useStore = create((set, get, api) => ({
count: 0,
increment: () => {
const state = get();
const newState = { count: state.count + 1 };
set(newState);
api.getState().someOtherAction();
// ...more complexity
},
}));Conclusion
Zustand provides a simple, powerful alternative to Redux. Use it for:
- Small to medium apps
- When you want minimal boilerplate
- When you need TypeScript support
- When you prefer hooks over providers
Master the basics, use TypeScript, leverage middleware, and follow best practices for optimal results.