π» Lesson 8.2: Zustand Basics
Welcome to hands-on state management with Zustand! If you found Redux overwhelming or Context API limiting, Zustand is the refreshing middle ground you've been looking for. With a tiny bundle size (~1KB), zero boilerplate, and incredible TypeScript support, Zustand has quickly become one of the most popular state management libraries in the React ecosystem. In this lesson, you'll learn everything you need to build production-ready applications with Zustand.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Install and set up Zustand in a React TypeScript project
- Create type-safe stores with proper TypeScript interfaces
- Use stores in components with optimal selector patterns
- Implement actions and update state immutably
- Understand and apply Zustand middleware (persist, devtools, immer)
- Create computed/derived values efficiently
- Organize stores in a scalable way
- Debug Zustand state with Redux DevTools
- Apply Zustand best practices for performance
- Migrate from Context API or other state solutions to Zustand
Estimated Time: 60-75 minutes
Prerequisites: Lesson 8.1 (State Management Overview), Modules 1-5
π In This Lesson
π€ What is Zustand?
Zustand (German for "state") is a small, fast, and scalable state management solution for React. Created by Poimandres (the team behind React Three Fiber and other innovative React libraries), Zustand takes a minimalist approach to state management.
π Definition
Zustand is a hook-based state management library that provides a simple API for creating global stores without the complexity of Redux or the performance issues of Context.
Why Choose Zustand?
No providers
Just hooks] C --> C1[Fine-grained updates
Only re-render what changed
Fast by default] D --> D1[~1KB gzipped
Zero dependencies
Tree-shakeable] E --> E1[Great TypeScript
Redux DevTools
Easy to learn] style A fill:#667eea,color:#fff
Zustand vs Other Solutions
| Feature | Zustand | Context API | Redux Toolkit |
|---|---|---|---|
| Bundle Size | ~1KB βββββ | 0KB (built-in) βββββ | ~20KB βββ |
| Boilerplate | Minimal βββββ | Low ββββ | Moderate βββ |
| Learning Curve | Very easy βββββ | Easy ββββ | Moderate βββ |
| Performance | Excellent βββββ | Can be slow ββ | Excellent βββββ |
| TypeScript | First-class βββββ | Good ββββ | First-class βββββ |
| DevTools | Redux DevTools ββββ | None β | Redux DevTools βββββ |
| Provider Needed | No βββββ | Yes ββ | Yes ββ |
When to Use Zustand
β Perfect For
- Most applications: Medium to large React apps
- Replacing Context: When Context causes performance issues
- Simpler than Redux: When Redux feels like overkill
- Quick prototypes: When you need global state fast
- Client-side state: UI state, cart, user preferences, etc.
β οΈ Consider Alternatives When
- Very large enterprise apps: Redux Toolkit's structure might be better
- Time-travel debugging is critical: Redux has better DevTools
- Team is already familiar with Redux: Stick with what works
- You only have 2-3 global values: Context API might be sufficient
Core Philosophy
Zustand follows these principles:
- No providers: Stores are just hooks you can use anywhere
- No actions/reducers required: Direct function calls to update state
- Immutability is optional: Use immer middleware if you prefer mutations
- Fine-grained subscriptions: Components only re-render when their selected data changes
- Outside React: Stores can be read/updated outside components
// This is valid Zustand - dead simple!
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}));
function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
π‘ The Magic of Zustand
Notice what's NOT in the code above:
- β No Provider wrapper
- β No action types or action creators
- β No reducer functions
- β No dispatch calls
- β No connect or mapStateToProps
Just a hook that returns state and functions. That's it!
βοΈ Installation and Setup
Getting started with Zustand is incredibly simple. Let's set up a new project and install Zustand.
Step 1: Create a React + TypeScript Project
If you don't have a project yet, create one with Vite:
# Create new project with Vite
npm create vite@latest my-zustand-app -- --template react-ts
# Navigate to project
cd my-zustand-app
# Install dependencies
npm install
Step 2: Install Zustand
# Install Zustand
npm install zustand
# That's it! No additional dependencies needed
β Installation Complete!
Zustand has zero dependencies and works out of the box. No configuration files, no setup stepsβyou're ready to create stores!
Optional: Install DevTools
For debugging, you can use Redux DevTools browser extension:
- Install Redux DevTools Extension:
- Enable in Zustand: Use the devtools middleware (we'll cover this later)
Project Structure
Here's a recommended folder structure for organizing Zustand stores:
src/
βββ stores/
β βββ useAuthStore.ts # User authentication store
β βββ useCartStore.ts # Shopping cart store
β βββ useUIStore.ts # UI state (theme, sidebar, etc.)
β βββ index.ts # Re-export all stores
βββ components/
β βββ ...
βββ App.tsx
βββ main.tsx
π‘ Organization Tip
Keep each store in its own file. This makes code easier to find, test, and maintain. The index.ts file can re-export everything for convenient imports:
// src/stores/index.ts
export { useAuthStore } from './useAuthStore';
export { useCartStore } from './useCartStore';
export { useUIStore } from './useUIStore';
// Now you can import like:
// import { useAuthStore, useCartStore } from '@/stores';
Verify Installation
Let's create a quick test to make sure everything works:
// src/stores/useCounterStore.ts
import { create } from 'zustand';
interface CounterStore {
count: number;
increment: () => void;
}
export const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}));
// src/App.tsx
import { useCounterStore } from './stores/useCounterStore';
function App() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>Increment</button>
</div>
);
}
export default App;
# Run your app
npm run dev
If you see a counter that increments when you click the button, congratulations! Zustand is working perfectly.
π You're All Set!
Zustand is installed and working. That's literally all the setup requiredβno providers, no configuration files, no boilerplate. Let's dive into creating real stores!
ποΈ Creating Your First Store
Let's learn how to create Zustand stores step by step, starting simple and building up to more complex patterns.
The Basics: A Simple Counter Store
The most basic Zustand store looks like this:
import { create } from 'zustand';
// Create a store
const useCounterStore = create((set) => ({
// Initial state
count: 0,
// Actions
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 })
}));
Let's break down what's happening:
create: Function from Zustand that creates a store- Store function: Receives
set(andget) as arguments set: Function to update state (like setState)- Return object: Contains both state and actions
Anatomy of a Zustand Store
The set Function
The set function is how you update state. It has two forms:
1. Object Merge (Partial Update)
const useStore = create((set) => ({
count: 0,
name: 'Alice',
// Only updates count, name stays the same
increment: () => set({ count: 1 })
}));
Note: Zustand automatically merges the object with existing state (shallow merge).
2. Function Form (Access Previous State)
const useStore = create((set) => ({
count: 0,
// Access previous state to compute new state
increment: () => set((state) => ({ count: state.count + 1 }))
}));
Use this form when: The new state depends on the old state.
The get Function
The get function lets you read the current state inside actions:
const useStore = create((set, get) => ({
count: 0,
multiplier: 2,
increment: () => set((state) => ({ count: state.count + 1 })),
// Use get to read other state values
incrementByMultiplier: () => {
const { count, multiplier } = get();
set({ count: count + multiplier });
},
// Or directly in computations
doubleCount: () => {
set({ count: get().count * 2 });
}
}));
Real-World Example: Todo Store
Let's create a more practical example:
// src/stores/useTodoStore.ts
import { create } from 'zustand';
interface Todo {
id: string;
text: string;
completed: boolean;
}
interface TodoStore {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
deleteTodo: (id: string) => void;
}
export const useTodoStore = create<TodoStore>((set) => ({
todos: [],
addTodo: (text) => set((state) => ({
todos: [
...state.todos,
{
id: Date.now().toString(),
text,
completed: false
}
]
})),
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)
}))
}));
β Best Practices Shown
- TypeScript interface: Defines the store shape
- Immutable updates: Using spread operators and array methods
- Clear action names: Self-documenting function names
- Consistent patterns: All actions follow the same structure
Store with Complex State
Stores can contain nested objects, arrays, and multiple pieces of state:
// src/stores/useShopStore.ts
import { create } from 'zustand';
interface Product {
id: string;
name: string;
price: number;
}
interface CartItem extends Product {
quantity: number;
}
interface ShopStore {
products: Product[];
cart: CartItem[];
loading: boolean;
error: string | null;
setProducts: (products: Product[]) => void;
addToCart: (product: Product) => void;
removeFromCart: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
clearCart: () => void;
}
export const useShopStore = create<ShopStore>((set, get) => ({
products: [],
cart: [],
loading: false,
error: null,
setProducts: (products) => set({ products }),
addToCart: (product) => set((state) => {
const existingItem = state.cart.find(item => item.id === product.id);
if (existingItem) {
// Increment quantity if already in cart
return {
cart: state.cart.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
};
} else {
// Add new item
return {
cart: [...state.cart, { ...product, quantity: 1 }]
};
}
}),
removeFromCart: (productId) => set((state) => ({
cart: state.cart.filter(item => item.id !== productId)
})),
updateQuantity: (productId, quantity) => set((state) => ({
cart: state.cart.map(item =>
item.id === productId
? { ...item, quantity }
: item
)
})),
clearCart: () => set({ cart: [] })
}));
π‘ Notice the Patterns
- Conditional logic: Can check existing state before updating
- Multiple updates: Can update several pieces of state at once
- Immutable arrays: Using map, filter, spread to avoid mutations
- TypeScript safety: Full type checking on all operations
Common Store Patterns
Loading and Error States
const useDataStore = create((set) => ({
data: null,
loading: false,
error: null,
fetchData: async () => {
set({ loading: true, error: null });
try {
const response = await fetch('/api/data');
const data = await response.json();
set({ data, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
}
}));
Reset Functionality
const initialState = {
count: 0,
name: ''
};
const useStore = create((set) => ({
...initialState,
increment: () => set((state) => ({ count: state.count + 1 })),
setName: (name: string) => set({ name }),
// Reset to initial state
reset: () => set(initialState)
}));
β οΈ Common Mistake
Don't mutate state directly!
// β BAD - Mutating state
addTodo: (text) => {
const { todos } = get();
todos.push({ id: Date.now().toString(), text, completed: false });
set({ todos }); // Same reference, React won't re-render!
}
// β
GOOD - Creating new array
addTodo: (text) => set((state) => ({
todos: [...state.todos, { id: Date.now().toString(), text, completed: false }]
}));
βοΈ Using Stores in Components
Now that we know how to create stores, let's learn how to use them in React components.
Basic Usage
Using a Zustand store is as simple as calling a hook:
import { useTodoStore } from '@/stores';
function TodoList() {
// Get state and actions from the store
const todos = useTodoStore((state) => state.todos);
const toggleTodo = useTodoStore((state) => state.toggleTodo);
const deleteTodo = useTodoStore((state) => state.deleteTodo);
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
);
}
The Selector Pattern
The function you pass to the store hook is called a selector. It determines what part of the state the component subscribes to:
// This component ONLY re-renders when todos array changes
const todos = useTodoStore((state) => state.todos);
// This component ONLY re-renders when toggleTodo function changes (never!)
const toggleTodo = useTodoStore((state) => state.toggleTodo);
π― The Power of Selectors
Selectors are the key to Zustand's performance. Components only re-render when the specific data they select changes, not when ANY part of the store changes.
Multiple Selectors
You can call the store hook multiple times in one component:
function TodoStats() {
const todos = useTodoStore((state) => state.todos);
const totalCount = todos.length;
const completedCount = todos.filter(t => t.completed).length;
return (
<div>
<p>Total: {totalCount}</p>
<p>Completed: {completedCount}</p>
<p>Remaining: {totalCount - completedCount}</p>
</div>
);
}
Selecting Multiple Values
When you need multiple values, you have options:
Option 1: Separate Selectors (Best Performance)
function UserProfile() {
const name = useAuthStore((state) => state.user?.name);
const email = useAuthStore((state) => state.user?.email);
const avatar = useAuthStore((state) => state.user?.avatar);
// Component only re-renders when name, email, or avatar changes
return <div>{name} - {email}</div>;
}
Option 2: Object Selector (Good for Related Data)
function UserProfile() {
const { name, email, avatar } = useAuthStore((state) => ({
name: state.user?.name,
email: state.user?.email,
avatar: state.user?.avatar
}));
// Re-renders when ANY of these three values change
return <div>{name} - {email}</div>;
}
Option 3: Entire Store (Use Sparingly)
// β οΈ WARNING: Re-renders on ANY store change
function DebugPanel() {
const store = useAuthStore(); // Gets entire store
return <pre>{JSON.stringify(store, null, 2)}</pre>;
}
β οΈ Performance Tip
Generally, use separate selectors (Option 1) for best performance. Group related data in one selector (Option 2) only when those values truly change together.
Using Actions
Actions are just functions in your store, so using them is straightforward:
function AddTodoForm() {
const [text, setText] = useState('');
const addTodo = useTodoStore((state) => state.addTodo);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (text.trim()) {
addTodo(text);
setText('');
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a todo..."
/>
<button type="submit">Add</button>
</form>
);
}
Accessing Store Outside Components
Sometimes you need to read or update the store from outside a React component:
// Access store state
const currentTodos = useTodoStore.getState().todos;
// Update store state
useTodoStore.getState().addTodo('New todo from outside React');
// Subscribe to changes
const unsubscribe = useTodoStore.subscribe((state) => {
console.log('Todos changed:', state.todos);
});
// Later: clean up subscription
unsubscribe();
Use cases:
- Event handlers outside components
- WebSocket callbacks
- Service workers
- Testing
π‘ Pro Tip: Custom Selector Hooks
Create custom hooks for commonly used selectors:
// In your store file
export const useTodos = () => useTodoStore((state) => state.todos);
export const useCompletedTodos = () =>
useTodoStore((state) => state.todos.filter(t => t.completed));
export const useActiveTodos = () =>
useTodoStore((state) => state.todos.filter(t => !t.completed));
// In components
function ActiveTodoList() {
const activeTodos = useActiveTodos();
return <ul>{/* ... */}</ul>;
}
π TypeScript Integration
One of Zustand's greatest strengths is its first-class TypeScript support. Let's explore how to build fully type-safe stores that catch errors at compile time and provide excellent IDE autocomplete.
Basic TypeScript Store Pattern
The recommended pattern uses an interface to define your store's shape:
import { create } from 'zustand';
// 1. Define the state interface
interface UserState {
user: User | null;
isLoading: boolean;
error: string | null;
// Action types
login: (email: string, password: string) => Promise<void>;
logout: () => void;
setUser: (user: User) => void;
}
// 2. Define data types
interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'user' | 'guest';
}
// 3. Create the store with full typing
export const useUserStore = create<UserState>()((set, get) => ({
// Initial state
user: null,
isLoading: false,
error: null,
// Actions with full type safety
login: async (email, password) => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) throw new Error('Login failed');
const user = await response.json();
set({ user, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Unknown error',
isLoading: false
});
}
},
logout: () => {
set({ user: null });
},
setUser: (user) => {
set({ user });
}
}));
β Pro Tip: Separate State and Actions
For better organization, you can separate your state and action types:
interface UserState {
user: User | null;
isLoading: boolean;
error: string | null;
}
interface UserActions {
login: (email: string, password: string) => Promise<void>;
logout: () => void;
setUser: (user: User) => void;
}
type UserStore = UserState & UserActions;
export const useUserStore = create<UserStore>()((set, get) => ({
// ... implementation
}));
Generic Type Helpers
Create reusable type helpers for common patterns:
// Async state wrapper
interface AsyncState<T> {
data: T | null;
isLoading: boolean;
error: string | null;
}
// Helper to create async state
function createAsyncState<T>(): AsyncState<T> {
return {
data: null,
isLoading: false,
error: null
};
}
// Usage in store
interface ProductStore {
products: AsyncState<Product[]>;
selectedProduct: AsyncState<Product>;
fetchProducts: () => Promise<void>;
fetchProduct: (id: string) => Promise<void>;
}
export const useProductStore = create<ProductStore>()((set) => ({
products: createAsyncState(),
selectedProduct: createAsyncState(),
fetchProducts: async () => {
set({ products: { ...createAsyncState(), isLoading: true } });
try {
const response = await fetch('/api/products');
const data = await response.json();
set({ products: { data, isLoading: false, error: null } });
} catch (error) {
set({
products: {
data: null,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch'
}
});
}
},
fetchProduct: async (id) => {
set({ selectedProduct: { ...createAsyncState(), isLoading: true } });
try {
const response = await fetch(`/api/products/${id}`);
const data = await response.json();
set({ selectedProduct: { data, isLoading: false, error: null } });
} catch (error) {
set({
selectedProduct: {
data: null,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch'
}
});
}
}
}));
Type-Safe Selectors
Ensure your selectors are fully typed:
// β
Typed selector
const user = useUserStore((state) => state.user);
// TypeScript knows: user is User | null
// β
Typed action
const login = useUserStore((state) => state.login);
// TypeScript knows: login is (email: string, password: string) => Promise<void>
// β
Computed value with proper typing
const isAdmin = useUserStore((state) => state.user?.role === 'admin');
// TypeScript knows: isAdmin is boolean
// β This would be a TypeScript error:
const wrong = useUserStore((state) => state.nonexistent);
// Error: Property 'nonexistent' does not exist
Immer Integration for Complex Updates
When working with nested objects, Immer middleware makes TypeScript updates much easier:
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
updateQuantity: (id: string, quantity: number) => void;
removeItem: (id: string) => void;
clearCart: () => void;
}
export const useCartStore = create<CartStore>()(
immer((set) => ({
items: [],
addItem: (item) => {
set((state) => {
// With Immer, we can "mutate" directly - it's actually immutable under the hood
const existingItem = state.items.find(i => i.id === item.id);
if (existingItem) {
existingItem.quantity += 1;
} else {
state.items.push({ ...item, quantity: 1 });
}
});
},
updateQuantity: (id, quantity) => {
set((state) => {
const item = state.items.find(i => i.id === id);
if (item) {
item.quantity = quantity;
}
});
},
removeItem: (id) => {
set((state) => {
state.items = state.items.filter(i => i.id !== id);
});
},
clearCart: () => {
set((state) => {
state.items = [];
});
}
}))
);
π Definition
Immer is a library that lets you write "mutating" code that actually produces immutable updates. It's perfect for complex nested state in Zustand stores, making TypeScript updates much cleaner and less error-prone.
Discriminated Unions for State Machines
Use TypeScript's discriminated unions to model complex state:
// Define all possible states using discriminated union
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
interface DataStore<T> {
state: RequestState<T>;
fetch: () => Promise<void>;
reset: () => void;
}
// Example: User profile store
export const useProfileStore = create<DataStore<UserProfile>>()((set) => ({
state: { status: 'idle' },
fetch: async () => {
set({ state: { status: 'loading' } });
try {
const response = await fetch('/api/profile');
const data = await response.json();
set({ state: { status: 'success', data } });
} catch (error) {
set({
state: {
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error'
}
});
}
},
reset: () => {
set({ state: { status: 'idle' } });
}
}));
// Usage in components with full type safety
function ProfileComponent() {
const state = useProfileStore((state) => state.state);
// TypeScript narrows the type based on status
if (state.status === 'loading') {
return <div>Loading...</div>;
}
if (state.status === 'error') {
return <div>Error: {state.error}</div>; // TypeScript knows state.error exists
}
if (state.status === 'success') {
return <div>{state.data.name}</div>; // TypeScript knows state.data exists
}
return <div>Click to load profile</div>;
}
π‘ Benefits of Discriminated Unions
- Type Safety: TypeScript ensures you handle all possible states
- No Impossible States: Can't have both loading and data at the same time
- Better Autocomplete: IDE knows exactly what properties are available
- Exhaustiveness Checking: TypeScript warns if you miss a case
β‘ Selectors and Performance
Selectors are the key to performance in Zustand. Understanding how they work and when components re-render is crucial for building fast applications.
How Selectors Work
Zustand uses shallow equality comparison by default. A component re-renders when the selected value changes:
// Example: Understanding re-renders
interface CounterStore {
count: number;
increment: () => void;
reset: () => void;
}
const useCounterStore = create<CounterStore>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
reset: () => set({ count: 0 })
}));
// Component 1: Only re-renders when count changes
function Counter() {
const count = useCounterStore((state) => state.count);
console.log('Counter rendered');
return <div>Count: {count}</div>;
}
// Component 2: Never re-renders (increment function reference is stable)
function IncrementButton() {
const increment = useCounterStore((state) => state.increment);
console.log('IncrementButton rendered');
return <button onClick={increment}>+1</button>;
}
// Component 3: Re-renders on EVERY store change (gets entire store)
function DebugPanel() {
const store = useCounterStore(); // β οΈ Gets entire store
console.log('DebugPanel rendered');
return <pre>{JSON.stringify(store, null, 2)}</pre>;
}
Selector Best Practices
1. Select Minimal Data
// β Bad: Selects too much data
function UserProfile() {
const { user, settings, preferences, notifications } = useAppStore();
return <div>{user.name}</div>; // Only needs user.name, but re-renders when anything changes
}
// β
Good: Selects only what's needed
function UserProfile() {
const userName = useAppStore((state) => state.user.name);
return <div>{userName}</div>; // Only re-renders when user.name changes
}
2. Use Primitive Values When Possible
// β
Good: Primitive value
const count = useCartStore((state) => state.items.length);
// β
Good: Primitive value
const isLoggedIn = useAuthStore((state) => state.user !== null);
// β οΈ Careful: Object (new reference each time)
const userInfo = useAuthStore((state) => ({
name: state.user?.name,
email: state.user?.email
})); // Creates new object on every render!
3. Memoize Object Selectors
When you need to return objects from selectors, use Zustand's shallow equality helper:
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
interface UserStore {
firstName: string;
lastName: string;
email: string;
setFirstName: (name: string) => void;
setLastName: (name: string) => void;
}
const useUserStore = create<UserStore>()((set) => ({
firstName: '',
lastName: '',
email: '',
setFirstName: (firstName) => set({ firstName }),
setLastName: (lastName) => set({ lastName })
}));
// β Without shallow: Re-renders even when values haven't changed
function UserName() {
const { firstName, lastName } = useUserStore((state) => ({
firstName: state.firstName,
lastName: state.lastName
})); // New object every time!
return <div>{firstName} {lastName}</div>;
}
// β
With shallow: Only re-renders when firstName or lastName actually change
function UserName() {
const { firstName, lastName } = useUserStore(
(state) => ({
firstName: state.firstName,
lastName: state.lastName
}),
shallow // Compare object properties, not object reference
);
return <div>{firstName} {lastName}</div>;
}
4. Create Custom Selector Hooks
Encapsulate commonly used selectors in custom hooks:
// store.ts
interface TodoStore {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
removeTodo: (id: string) => void;
}
export const useTodoStore = create<TodoStore>()(/* ... */);
// Custom selector hooks
export const useAllTodos = () =>
useTodoStore((state) => state.todos);
export const useActiveTodos = () =>
useTodoStore((state) => state.todos.filter(t => !t.completed));
export const useCompletedTodos = () =>
useTodoStore((state) => state.todos.filter(t => t.completed));
export const useTodoCount = () =>
useTodoStore((state) => ({
total: state.todos.length,
active: state.todos.filter(t => !t.completed).length,
completed: state.todos.filter(t => t.completed).length
}), shallow);
// Usage in components - clean and simple!
function TodoList() {
const todos = useAllTodos(); // Clear, reusable
return <ul>{todos.map(t => <TodoItem key={t.id} todo={t} />)}</ul>;
}
function TodoStats() {
const { total, active, completed } = useTodoCount();
return (
<div>
Total: {total} | Active: {active} | Completed: {completed}
</div>
);
}
β Benefits of Custom Selector Hooks
- Reusability: Use the same selector in multiple components
- Consistency: Same logic everywhere
- Maintainability: Change selector logic in one place
- Testability: Easy to test selectors independently
- Readability: Clear intent in component code
Computing Derived Values
Derived values should be computed in selectors, not in the store:
interface ShoppingCartStore {
items: CartItem[];
discount: number; // percentage
taxRate: number; // percentage
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
}
const useCartStore = create<ShoppingCartStore>()(/* ... */);
// β
Compute derived values in selectors
export const useCartTotals = () =>
useCartStore((state) => {
const subtotal = state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const discountAmount = subtotal * (state.discount / 100);
const afterDiscount = subtotal - discountAmount;
const tax = afterDiscount * (state.taxRate / 100);
const total = afterDiscount + tax;
return { subtotal, discountAmount, tax, total };
}, shallow);
// Usage
function CartSummary() {
const { subtotal, discountAmount, tax, total } = useCartTotals();
return (
<div>
<p>Subtotal: ${subtotal.toFixed(2)}</p>
<p>Discount: -${discountAmount.toFixed(2)}</p>
<p>Tax: ${tax.toFixed(2)}</p>
<p><strong>Total: ${total.toFixed(2)}</strong></p>
</div>
);
}
β οΈ Performance Warning
Complex calculations in selectors run on every render. For expensive computations, consider:
- Store computed values: Calculate once, store in state
- Use useMemo: Cache results in the component
- Debounce updates: Reduce calculation frequency
// Example: Cache expensive computation
function ExpensiveComponent() {
const data = useDataStore((state) => state.largeDataset);
const processedData = useMemo(() => {
// Expensive processing only when data changes
return data.map(/* complex transformation */);
}, [data]);
return <div>{/* use processedData */}</div>;
}
Testing Selectors
Custom selector hooks make testing much easier:
// __tests__/store.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCartStore, useCartTotals } from './store';
describe('Cart Store', () => {
beforeEach(() => {
// Reset store before each test
useCartStore.setState({ items: [], discount: 0, taxRate: 8 });
});
it('calculates totals correctly', () => {
const { result } = renderHook(() => useCartTotals());
// Initially empty
expect(result.current.total).toBe(0);
// Add items
act(() => {
useCartStore.getState().addItem({
id: '1',
name: 'Widget',
price: 100,
quantity: 2
});
});
// Check calculations
expect(result.current.subtotal).toBe(200);
expect(result.current.tax).toBe(16); // 8% of 200
expect(result.current.total).toBe(216);
});
});
π¬ Actions and State Updates
Actions are functions in your store that update state. Let's explore patterns for creating clean, maintainable actions.
Basic Actions
Actions use the set function to update state:
interface CounterStore {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
setCount: (count: number) => void;
}
const useCounterStore = create<CounterStore>()((set) => ({
count: 0,
// Simple state replacement
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
// Reset to initial value
reset: () => set({ count: 0 }),
// Set to specific value
setCount: (count) => set({ count })
}));
The set Function
Zustand's set function has two forms:
// Form 1: Partial state object (merged with existing state)
set({ count: 5 });
// Form 2: Updater function (receives current state)
set((state) => ({ count: state.count + 1 }));
// With replace flag (replaces entire state, not just merge)
set({ count: 0 }, true); // β οΈ Removes all other properties!
β οΈ Watch Out: Merge vs Replace
By default, set merges the new state with the existing state. The second parameter (true) replaces the entire state:
// Merge (default)
set({ count: 5 }); // Keeps all other properties, updates only count
// Replace
set({ count: 5 }, true); // Removes all other properties! Usually not what you want
The get Function
The get function lets you read current state within actions:
interface TodoStore {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
addTodo: (text: string) => void;
toggleAllTodos: () => void;
}
const useTodoStore = create<TodoStore>()((set, get) => ({
todos: [],
filter: 'all',
addTodo: (text) => {
const currentTodos = get().todos; // Read current state
const newTodo: Todo = {
id: Date.now().toString(),
text,
completed: false
};
set({ todos: [...currentTodos, newTodo] });
},
toggleAllTodos: () => {
const currentTodos = get().todos;
const allCompleted = currentTodos.every(t => t.completed);
set({
todos: currentTodos.map(todo => ({
...todo,
completed: !allCompleted
}))
});
}
}));
Async Actions
Async actions work seamlessly in Zustand:
interface UserStore {
users: User[];
isLoading: boolean;
error: string | null;
fetchUsers: () => Promise<void>;
createUser: (userData: Omit<User, 'id'>) => Promise<User>;
updateUser: (id: string, updates: Partial<User>) => Promise<void>;
deleteUser: (id: string) => Promise<void>;
}
const useUserStore = create<UserStore>()((set, get) => ({
users: [],
isLoading: false,
error: null,
fetchUsers: async () => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Failed to fetch users');
const users = await response.json();
set({ users, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Unknown error',
isLoading: false
});
}
},
createUser: async (userData) => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});
if (!response.ok) throw new Error('Failed to create user');
const newUser = await response.json();
const currentUsers = get().users;
set({
users: [...currentUsers, newUser],
isLoading: false
});
return newUser;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Unknown error',
isLoading: false
});
throw error;
}
},
updateUser: async (id, updates) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`/api/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!response.ok) throw new Error('Failed to update user');
const updatedUser = await response.json();
const currentUsers = get().users;
set({
users: currentUsers.map(user =>
user.id === id ? updatedUser : user
),
isLoading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Unknown error',
isLoading: false
});
throw error;
}
},
deleteUser: async (id) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`/api/users/${id}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete user');
const currentUsers = get().users;
set({
users: currentUsers.filter(user => user.id !== id),
isLoading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Unknown error',
isLoading: false
});
throw error;
}
}
}));
β Async Action Best Practices
- Set loading state: Before starting async operation
- Handle errors: Use try-catch and store errors in state
- Clear errors: Reset error state before new attempts
- Return values: Return data from actions when needed
- Throw on error: Let components handle errors if needed
Optimistic Updates
Update UI immediately, roll back if the server request fails:
interface TodoStore {
todos: Todo[];
toggleTodo: (id: string) => Promise<void>;
}
const useTodoStore = create<TodoStore>()((set, get) => ({
todos: [],
toggleTodo: async (id) => {
// Save current state for rollback
const previousTodos = get().todos;
// Optimistic update - update UI immediately
set({
todos: previousTodos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
});
try {
// Send to server
const response = await fetch(`/api/todos/${id}/toggle`, {
method: 'PATCH'
});
if (!response.ok) throw new Error('Failed to toggle todo');
// Server confirmed - we're good!
} catch (error) {
// Rollback on error
set({ todos: previousTodos });
// Show error to user
console.error('Failed to toggle todo:', error);
// Could also set an error state here
}
}
}));
Batching Updates
Multiple set calls are automatically batched in React 18+:
const useAppStore = create<AppStore>()((set) => ({
// ...
initialize: async () => {
// These updates are batched - only one render
set({ isLoading: true });
set({ error: null });
set({ initialized: false });
try {
const [users, settings, notifications] = await Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/settings').then(r => r.json()),
fetch('/api/notifications').then(r => r.json())
]);
// These are also batched
set({ users });
set({ settings });
set({ notifications });
set({ isLoading: false });
set({ initialized: true });
} catch (error) {
set({ error: error.message, isLoading: false });
}
}
}));
π‘ Manual Batching
You can also combine updates into a single set call:
// Instead of multiple set calls:
set({ isLoading: true });
set({ error: null });
set({ data: newData });
// Combine into one:
set({
isLoading: true,
error: null,
data: newData
});
Immutable Updates with Immer
For deeply nested state, Immer middleware makes updates much cleaner:
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface NestedStore {
user: {
profile: {
name: string;
email: string;
settings: {
theme: string;
notifications: {
email: boolean;
push: boolean;
sms: boolean;
};
};
};
};
updateEmailNotifications: (enabled: boolean) => void;
}
// Without Immer - complex and error-prone
const useStoreWithoutImmer = create<NestedStore>()((set) => ({
user: { /* ... */ },
updateEmailNotifications: (enabled) => {
set((state) => ({
user: {
...state.user,
profile: {
...state.user.profile,
settings: {
...state.user.profile.settings,
notifications: {
...state.user.profile.settings.notifications,
email: enabled
}
}
}
}
}));
}
}));
// With Immer - simple and clear
const useStoreWithImmer = create<NestedStore>()(
immer((set) => ({
user: { /* ... */ },
updateEmailNotifications: (enabled) => {
set((state) => {
// "Mutate" directly - Immer makes it immutable
state.user.profile.settings.notifications.email = enabled;
});
}
}))
);
β When to Use Immer
- Deeply nested state structures
- Complex array manipulations
- Frequent updates to nested objects
- When readability is more important than the tiny performance cost
Note: Immer adds ~16KB to your bundle, so use it only when beneficial.
π Middleware
Middleware extends Zustand's functionality. Think of middleware as plugins that wrap your store to add features like persistence, logging, or Redux DevTools integration.
Persist Middleware
The persist middleware saves your store to localStorage (or other storage) automatically:
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface SettingsStore {
theme: 'light' | 'dark';
language: string;
fontSize: number;
setTheme: (theme: 'light' | 'dark') => void;
setLanguage: (language: string) => void;
setFontSize: (fontSize: number) => void;
}
export const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
// Initial state
theme: 'light',
language: 'en',
fontSize: 16,
// Actions
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
setFontSize: (fontSize) => set({ fontSize })
}),
{
name: 'app-settings', // Storage key
storage: createJSONStorage(() => localStorage), // Default
}
)
);
Now the settings automatically save to localStorage and restore on page reload!
Persist Configuration Options
persist(
(set, get) => ({ /* store */ }),
{
name: 'my-store', // Required: localStorage key
storage: createJSONStorage(() => localStorage), // Storage engine
// Partial persistence - only save specific fields
partialize: (state) => ({
theme: state.theme,
language: state.language
// fontSize is NOT persisted
}),
// Version your storage for migrations
version: 1,
// Migrate data when version changes
migrate: (persistedState: any, version: number) => {
if (version === 0) {
// Upgrade from version 0 to 1
return {
...persistedState,
newField: 'default value'
};
}
return persistedState;
},
// Skip hydration on mount (useful for SSR)
skipHydration: false,
// Merge strategy
merge: (persistedState, currentState) => ({
...currentState,
...persistedState
})
}
)
π‘ Alternative Storage Engines
You can use different storage backends:
// SessionStorage
storage: createJSONStorage(() => sessionStorage)
// AsyncStorage (React Native)
import AsyncStorage from '@react-native-async-storage/async-storage';
storage: createJSONStorage(() => AsyncStorage)
// IndexedDB (using idb-keyval)
import { get, set, del } from 'idb-keyval';
storage: {
getItem: async (name: string): Promise<string | null> => {
return (await get(name)) || null;
},
setItem: async (name: string, value: string): Promise<void> => {
await set(name, value);
},
removeItem: async (name: string): Promise<void> => {
await del(name);
},
}
Manual Hydration Control
Sometimes you need to control when the persisted state is loaded:
export const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({ /* ... */ }),
{
name: 'app-settings',
skipHydration: true, // Don't auto-load on creation
}
)
);
// Later, in your app initialization:
function App() {
useEffect(() => {
// Manually trigger hydration when ready
useSettingsStore.persist.rehydrate();
}, []);
return <div>{/* app */}</div>;
}
DevTools Middleware
Integrate with Redux DevTools for debugging:
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface CounterStore {
count: number;
increment: () => void;
decrement: () => void;
}
export const useCounterStore = create<CounterStore>()(
devtools(
(set) => ({
count: 0,
// Action names will appear in DevTools
increment: () => set(
(state) => ({ count: state.count + 1 }),
false,
'counter/increment' // Action name for DevTools
),
decrement: () => set(
(state) => ({ count: state.count - 1 }),
false,
'counter/decrement'
)
}),
{ name: 'CounterStore' } // Store name in DevTools
)
);
Install the Redux DevTools browser extension to see your Zustand state and actions!
β οΈ DevTools in Production
DevTools middleware should typically be disabled in production:
const middleware = process.env.NODE_ENV === 'development'
? devtools
: (f: any) => f;
export const useStore = create<Store>()(
middleware(
(set) => ({ /* ... */ }),
{ name: 'MyStore' }
)
);
Immer Middleware
We've seen Immer in action already, but let's dive deeper into its capabilities:
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface ComplexStore {
users: User[];
posts: Post[];
comments: { [postId: string]: Comment[] };
addUser: (user: User) => void;
updateUser: (id: string, updates: Partial<User>) => void;
addPost: (post: Post) => void;
addComment: (postId: string, comment: Comment) => void;
likePost: (postId: string) => void;
}
export const useComplexStore = create<ComplexStore>()(
immer((set) => ({
users: [],
posts: [],
comments: {},
addUser: (user) => {
set((state) => {
state.users.push(user);
});
},
updateUser: (id, updates) => {
set((state) => {
const user = state.users.find(u => u.id === id);
if (user) {
Object.assign(user, updates);
}
});
},
addPost: (post) => {
set((state) => {
state.posts.push(post);
state.comments[post.id] = []; // Initialize comments array
});
},
addComment: (postId, comment) => {
set((state) => {
if (!state.comments[postId]) {
state.comments[postId] = [];
}
state.comments[postId].push(comment);
});
},
likePost: (postId) => {
set((state) => {
const post = state.posts.find(p => p.id === postId);
if (post) {
post.likes = (post.likes || 0) + 1;
}
});
}
}))
);
β Immer Benefits
- Readability: Code looks like simple mutations
- Less Boilerplate: No spread operators everywhere
- Fewer Bugs: Easier to write correct updates
- Array Methods: Use push, splice, etc. directly
- Nested Updates: Update deep properties easily
Combining Multiple Middleware
You can compose multiple middleware together. Order matters!
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { devtools } from 'zustand/middleware';
interface TodoStore {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
removeTodo: (id: string) => void;
}
// Middleware composition: immer -> devtools -> persist
// Order: innermost to outermost
export const useTodoStore = create<TodoStore>()(
persist(
devtools(
immer((set) => ({
todos: [],
addTodo: (text) => {
set((state) => {
state.todos.push({
id: Date.now().toString(),
text,
completed: false
});
});
},
toggleTodo: (id) => {
set((state) => {
const todo = state.todos.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
});
},
removeTodo: (id) => {
set((state) => {
state.todos = state.todos.filter(t => t.id !== id);
});
}
})),
{ name: 'TodoStore' }
),
{ name: 'todos-storage' }
)
);
π‘ Middleware Order Guide
Recommended order (innermost to outermost):
- Immer: Closest to your store logic
- DevTools: Wraps Immer, sees "clean" actions
- Persist: Outermost, handles storage
create(
persist( // 3. Outermost - saves to storage
devtools( // 2. Middle - logs to DevTools
immer( // 1. Innermost - simplifies updates
(set) => ({ /* your store */ })
)
)
)
)
Creating Custom Middleware
You can create your own middleware to extend Zustand:
import { StateCreator, StoreMutatorIdentifier } from 'zustand';
// Example: Logger middleware that logs all state changes
type Logger = <
T,
Mps extends [StoreMutatorIdentifier, unknown][] = [],
Mcs extends [StoreMutatorIdentifier, unknown][] = []
>(
f: StateCreator<T, Mps, Mcs>,
name?: string
) => StateCreator<T, Mps, Mcs>;
const logger: Logger = (f, name) => (set, get, store) => {
const loggedSet: typeof set = (...args) => {
console.log(`[${name || 'store'}] Setting state:`, args[0]);
set(...args);
console.log(`[${name || 'store'}] New state:`, get());
};
return f(loggedSet, get, store);
};
// Usage
interface CounterStore {
count: number;
increment: () => void;
}
export const useCounterStore = create<CounterStore>()(
logger(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}),
'CounterStore'
)
);
Example: Reset Middleware
Middleware that adds a reset function to any store:
import { StateCreator } from 'zustand';
// Middleware that adds reset functionality
const resetters: (() => void)[] = [];
export const resetAllStores = () => {
resetters.forEach((resetter) => resetter());
};
type Resettable<T> = T & { reset: () => void };
export const resettable = <T extends object>(
config: StateCreator<T>
): StateCreator<Resettable<T>> => {
return (set, get, api) => {
const initialState = config(set, get, api);
resetters.push(() => {
set(initialState, true); // Replace entire state
});
return {
...initialState,
reset: () => {
set(initialState, true);
}
};
};
};
// Usage
interface UserStore {
name: string;
email: string;
setName: (name: string) => void;
setEmail: (email: string) => void;
}
export const useUserStore = create<UserStore>()(
resettable((set) => ({
name: '',
email: '',
setName: (name) => set({ name }),
setEmail: (email) => set({ email })
}))
);
// Now you can reset the store
function UserProfile() {
const { name, email, reset } = useUserStore();
return (
<div>
<p>{name} - {email}</p>
<button onClick={reset}>Reset</button>
</div>
);
}
// Or reset all stores at once
resetAllStores();
β οΈ Middleware Complexity
Creating custom middleware requires understanding Zustand's TypeScript types. Start with the built-in middleware and only create custom middleware when you have a clear, reusable pattern that appears across multiple stores.
β Best Practices
Let's consolidate everything we've learned into actionable best practices for using Zustand effectively.
Store Organization
1. One Store Per Domain
Organize stores by feature/domain, not by data type:
// β
Good: Domain-based stores
// stores/auth.ts
export const useAuthStore = create<AuthStore>()(/* auth logic */);
// stores/cart.ts
export const useCartStore = create<CartStore>()(/* cart logic */);
// stores/products.ts
export const useProductStore = create<ProductStore>()(/* product logic */);
// β Bad: Generic data stores
export const useDataStore = create<DataStore>()((set) => ({
users: [],
products: [],
orders: [],
// Everything mixed together
}));
2. Colocate Related State
Keep related state together in the same store:
// β
Good: Related state together
interface TodoStore {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
searchQuery: string;
// Actions that work with this related state
addTodo: (text: string) => void;
setFilter: (filter: 'all' | 'active' | 'completed') => void;
setSearchQuery: (query: string) => void;
}
// β Bad: Related state split across stores
const useTodoStore = create<TodoState>()(/* todos */);
const useFilterStore = create<FilterState>()(/* filter */);
const useSearchStore = create<SearchState>()(/* search */);
3. File Structure
src/
stores/
auth.ts // Auth store
cart.ts // Shopping cart store
products.ts // Product catalog store
ui.ts // UI state (modals, theme, etc.)
types.ts // Shared types
index.ts // Re-export all stores
hooks/
useAuthGuard.ts // Custom hooks using stores
useCartTotals.ts
components/
auth/
LoginForm.tsx // Uses useAuthStore
cart/
CartSummary.tsx // Uses useCartStore
Performance Optimization
1. Selector Patterns
// β
Best: Primitive values
const count = useCartStore((state) => state.items.length);
// β
Good: Multiple primitives with shallow
const { firstName, lastName } = useUserStore(
(state) => ({ firstName: state.firstName, lastName: state.lastName }),
shallow
);
// β οΈ Careful: Derived objects (use useMemo in component)
const stats = useCartStore((state) => ({
total: state.items.reduce((sum, item) => sum + item.price, 0),
count: state.items.length
}), shallow);
// β Bad: Entire store
const store = useCartStore(); // Re-renders on ANY change!
2. Memoize Expensive Selectors
import { useMemo } from 'react';
function ProductList() {
const products = useProductStore((state) => state.products);
const filters = useProductStore((state) => state.filters);
// Memoize expensive filtering
const filteredProducts = useMemo(() => {
return products.filter(product => {
// Complex filtering logic
return (
product.category === filters.category &&
product.price >= filters.minPrice &&
product.price <= filters.maxPrice
);
}).sort((a, b) => {
// Complex sorting logic
return a.price - b.price;
});
}, [products, filters]);
return <div>{/* render filteredProducts */}</div>;
}
3. Split Large Components
// β Bad: One component subscribes to everything
function UserDashboard() {
const { profile, settings, notifications, orders } = useUserStore();
return (
<div>
<ProfileSection profile={profile} />
<SettingsSection settings={settings} />
<NotificationsSection notifications={notifications} />
<OrdersSection orders={orders} />
</div>
);
}
// β
Good: Each component subscribes to what it needs
function UserDashboard() {
return (
<div>
<ProfileSection />
<SettingsSection />
<NotificationsSection />
<OrdersSection />
</div>
);
}
function ProfileSection() {
const profile = useUserStore((state) => state.profile);
return <div>{/* profile UI */}</div>;
}
function SettingsSection() {
const settings = useUserStore((state) => state.settings);
return <div>{/* settings UI */}</div>;
}
State Management Patterns
1. Separate Actions from State
// β
Good: Clear separation
interface TodoState {
todos: Todo[];
isLoading: boolean;
error: string | null;
}
interface TodoActions {
fetchTodos: () => Promise<void>;
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
removeTodo: (id: string) => void;
}
type TodoStore = TodoState & TodoActions;
2. Use Discriminated Unions for Complex State
// β
Good: Impossible states are impossible
type DataState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
// β Bad: Can be in impossible state
interface DataState<T> {
data: T | null;
isLoading: boolean;
error: string | null;
// Could be: isLoading=true, data=something, error=something π€
}
3. Normalize Nested Data
// β Bad: Nested arrays are hard to update
interface AppStore {
users: {
id: string;
name: string;
posts: {
id: string;
title: string;
comments: Comment[];
}[];
}[];
}
// β
Good: Normalized structure
interface AppStore {
users: { [id: string]: User };
posts: { [id: string]: Post };
comments: { [id: string]: Comment };
userPosts: { [userId: string]: string[] }; // Post IDs
postComments: { [postId: string]: string[] }; // Comment IDs
}
Testing
// stores/__tests__/cart.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCartStore } from '../cart';
describe('Cart Store', () => {
beforeEach(() => {
// Reset store before each test
useCartStore.setState({
items: [],
discount: 0,
taxRate: 8
});
});
it('adds items to cart', () => {
const { result } = renderHook(() => useCartStore());
act(() => {
result.current.addItem({
id: '1',
name: 'Test Product',
price: 100,
quantity: 1
});
});
expect(result.current.items).toHaveLength(1);
expect(result.current.items[0].name).toBe('Test Product');
});
it('updates item quantity', () => {
const { result } = renderHook(() => useCartStore());
// Setup
act(() => {
result.current.addItem({
id: '1',
name: 'Test Product',
price: 100,
quantity: 1
});
});
// Test
act(() => {
result.current.updateQuantity('1', 3);
});
expect(result.current.items[0].quantity).toBe(3);
});
it('calculates total correctly', () => {
const { result } = renderHook(() => useCartStore());
act(() => {
result.current.addItem({
id: '1',
name: 'Product 1',
price: 100,
quantity: 2
});
result.current.addItem({
id: '2',
name: 'Product 2',
price: 50,
quantity: 1
});
});
const total = result.current.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
expect(total).toBe(250);
});
});
Common Pitfalls to Avoid
β οΈ Common Mistakes
- Mutating state directly: Always use
set() - Over-selecting: Don't select more than you need
- Forgetting shallow: Use shallow for object selectors
- Not handling errors: Always handle async errors
- Global state for everything: Local state is fine!
- Too many stores: Don't split unnecessarily
- Not using TypeScript: Types prevent many bugs
When NOT to Use Zustand
Zustand isn't always the answer. Consider alternatives when:
- Component-local state: Use
useStatefor state that doesn't need to be shared - URL state: Use React Router for navigation state
- Form state: Consider React Hook Form or Formik
- Server cache: Use React Query or SWR for server data
- Simple prop drilling: 2-3 levels of props is fine
β The Golden Rule
Use Zustand when you need to share state across multiple components that aren't closely related in the component tree. For everything else, start with the simplest solution (local state, props) and only reach for Zustand when you feel the pain of prop drilling or complex state coordination.
π Summary
Congratulations! You've mastered Zustand, one of the most elegant state management solutions in the React ecosystem. Let's recap what you've learned:
Key Takeaways
π― What You've Learned
- Zustand Fundamentals: Create and use stores with minimal boilerplate
- TypeScript Integration: Build fully type-safe stores with interfaces and generics
- Performance Optimization: Use selectors effectively to prevent unnecessary re-renders
- State Updates: Implement actions, handle async operations, and manage complex state
- Middleware: Extend functionality with persist, devtools, immer, and custom middleware
- Best Practices: Organize stores, optimize performance, and avoid common pitfalls
Zustand vs Other Solutions
| Feature | Zustand | Redux Toolkit | Context API |
|---|---|---|---|
| Bundle Size | ~1KB | ~8KB | 0 (built-in) |
| Boilerplate | Minimal | Moderate | Minimal |
| Learning Curve | Easy | Steep | Easy |
| Performance | Excellent | Excellent | Can cause re-renders |
| DevTools | Yes (via middleware) | Yes (built-in) | No |
| TypeScript | Excellent | Good | Good |
| Middleware | Yes | Yes | No |
Quick Reference
Creating a Store
import { create } from 'zustand';
interface Store {
count: number;
increment: () => void;
}
export const useStore = create<Store>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}));
Using Selectors
// Primitive value
const count = useStore((state) => state.count);
// Object with shallow
const { count, name } = useStore(
(state) => ({ count: state.count, name: state.name }),
shallow
);
// Action
const increment = useStore((state) => state.increment);
Middleware Composition
import { create } from 'zustand';
import { persist, devtools, immer } from 'zustand/middleware';
export const useStore = create<Store>()(
persist(
devtools(
immer((set) => ({ /* store */ }))
),
{ name: 'storage-key' }
)
);
Async Actions
fetchData: async () => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/data');
const data = await response.json();
set({ data, isLoading: false });
} catch (error) {
set({
error: error.message,
isLoading: false
});
}
}
Next Steps
Now that you've mastered Zustand, here's how to continue your journey:
- Practice: Build small projects using Zustand
- Explore: Try different middleware combinations
- Compare: In Lesson 8.3, we'll explore Redux Toolkit
- Integrate: Combine Zustand with React Query for server state
- Contribute: Check out Zustand's GitHub repo and community
π‘ Pro Tip: Start Simple
Don't overthink your state management architecture. Start with a simple Zustand store and only add middleware or complexity as you actually need it. The beauty of Zustand is that it grows with your application.
Resources
ποΈ Practice Exercises
Exercise 1: User Authentication Store
Objective: Create a complete authentication store with TypeScript.
Requirements:
- Store user data, loading state, and errors
- Implement login, logout, and register actions
- Use persist middleware to save auth state
- Add TypeScript types for all state and actions
- Handle async errors properly
π‘ Hint
Start with this structure:
interface User {
id: string;
email: string;
name: string;
}
interface AuthState {
user: User | null;
isLoading: boolean;
error: string | null;
}
interface AuthActions {
login: (email: string, password: string) => Promise<void>;
logout: () => void;
register: (email: string, password: string, name: string) => Promise<void>;
}
β Solution
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface User {
id: string;
email: string;
name: string;
}
interface AuthState {
user: User | null;
isLoading: boolean;
error: string | null;
}
interface AuthActions {
login: (email: string, password: string) => Promise<void>;
logout: () => void;
register: (email: string, password: string, name: string) => Promise<void>;
clearError: () => void;
}
type AuthStore = AuthState & AuthActions;
export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
user: null,
isLoading: false,
error: null,
login: async (email, password) => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error('Invalid credentials');
}
const user = await response.json();
set({ user, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Login failed',
isLoading: false
});
}
},
logout: () => {
set({ user: null, error: null });
},
register: async (email, password, name) => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name })
});
if (!response.ok) {
throw new Error('Registration failed');
}
const user = await response.json();
set({ user, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Registration failed',
isLoading: false
});
}
},
clearError: () => {
set({ error: null });
}
}),
{
name: 'auth-storage',
partialize: (state) => ({ user: state.user }) // Only persist user
}
)
);
Exercise 2: Shopping Cart with Calculations
Objective: Build a shopping cart with derived calculations using custom selector hooks.
Requirements:
- Store cart items with quantity and price
- Implement add, remove, and update quantity actions
- Create custom selector hooks for totals
- Use Immer middleware for easier updates
- Calculate subtotal, discount, tax, and total
π‘ Hint
Create a custom hook for totals:
export const useCartTotals = () =>
useCartStore((state) => {
const subtotal = state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
// Calculate discount, tax, total
return { subtotal, discount, tax, total };
}, shallow);
Exercise 3: Todo App with Filters
Objective: Create a todo application with filtering and persistence.
Requirements:
- Store todos with completed status
- Implement filter state (all, active, completed)
- Create custom selector hooks for filtered todos
- Use persist middleware to save todos
- Add DevTools middleware for debugging
β Knowledge Check
Question 1: Selector Performance
Which selector pattern is most performant?
Show Answer
Answer: B
Selecting a primitive value is most performant because it only re-renders when that specific value changes. Option A re-renders on any store change. Option C creates a new object reference each time, causing unnecessary re-renders unless you use shallow.
Question 2: Middleware Order
What's the correct order for combining middleware?
Show Answer
Answer: B
Correct order (innermost to outermost): immer (simplifies updates) β devtools (logs actions) β persist (handles storage). This ensures Immer processes updates first, DevTools sees clean actions, and persist handles the final state.
Question 3: State Updates
Which statement is true about Zustand's set function?
Show Answer
Answer: B
By default, set performs a shallow merge of the new state with the existing state. You can pass true as a second parameter to replace the entire state, but this is rarely needed. Immer is optional and just makes nested updates easier.