Skip to main content

🐻 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?

graph TB A[Why Zustand?] --> B[Simplicity] A --> C[Performance] A --> D[Size] A --> E[DX] B --> B1[No boilerplate
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:

  1. No providers: Stores are just hooks you can use anywhere
  2. No actions/reducers required: Direct function calls to update state
  3. Immutability is optional: Use immer middleware if you prefer mutations
  4. Fine-grained subscriptions: Components only re-render when their selected data changes
  5. 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:

  1. Install Redux DevTools Extension:
  2. 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:

  1. create: Function from Zustand that creates a store
  2. Store function: Receives set (and get) as arguments
  3. set: Function to update state (like setState)
  4. Return object: Contains both state and actions

Anatomy of a Zustand Store

graph LR A[create Function] --> B[Store Creator] B --> C[set Function] B --> D[get Function] C --> E[Update State] D --> F[Read State] E --> G[Store Object] F --> G G --> H[State Values] G --> I[Action Functions] style A fill:#667eea,color:#fff style G fill:#4CAF50,color:#fff

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:

graph TD A[Component calls useStore selector] --> B{Has selected value changed?} B -->|Yes - Different reference| C[Component re-renders] B -->|No - Same reference| D[Component does not re-render] C --> E[Shallow comparison using Object.is] D --> E style A fill:#e3f2fd style C fill:#ffcdd2 style D fill:#c8e6c9
⚑ Interactive: See Which Components Re-render

Click the buttons below to update different parts of the store. Watch which components flash (re-render) vs stay static. This demonstrates why selective subscriptions are crucial for performance.

πŸ“¦ Store State

count: 0
name: "Alice"
theme: "light"

βš›οΈ Components

Counter state.count
Count: 0
UserName state.name
Hello, Alice!
ThemeIndicator state.theme
β˜€οΈ light
⚠️ DebugPanel entire store
{count, name, theme}
0
Counter Renders
0
UserName Renders
0
Theme Renders
0
Debug Renders

πŸ’‘ Key Insight: Notice how DebugPanel (red border) re-renders on EVERY update because it selects the entire store, while other components only re-render when their specific data changes. This is why selective subscriptions are crucial!

// 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:

  1. Store computed values: Calculate once, store in state
  2. Use useMemo: Cache results in the component
  3. 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

  1. Set loading state: Before starting async operation
  2. Handle errors: Use try-catch and store errors in state
  3. Clear errors: Reset error state before new attempts
  4. Return values: Return data from actions when needed
  5. 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.

graph LR A[Component] --> B[Middleware Layer] B --> C[Store] C --> D[State] B --> E[Persist Middleware] B --> F[DevTools Middleware] B --> G[Immer Middleware] style A fill:#e3f2fd style B fill:#fff3cd style C fill:#c8e6c9 style D fill:#f0f0f0

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):

  1. Immer: Closest to your store logic
  2. DevTools: Wraps Immer, sees "clean" actions
  3. 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

  1. Mutating state directly: Always use set()
  2. Over-selecting: Don't select more than you need
  3. Forgetting shallow: Use shallow for object selectors
  4. Not handling errors: Always handle async errors
  5. Global state for everything: Local state is fine!
  6. Too many stores: Don't split unnecessarily
  7. 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 useState for 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:

  1. Practice: Build small projects using Zustand
  2. Explore: Try different middleware combinations
  3. Compare: In Lesson 8.3, we'll explore Redux Toolkit
  4. Integrate: Combine Zustand with React Query for server state
  5. 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.