AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

Zustand Guide: Lightweight State Management for React 19

Last updated:
Controlled vs Uncontrolled: React Form State Patterns

Master Zustand for simple, scalable state management. Learn store creation, hooks patterns, middleware, async actions, and DevTools integration with production examples.

# Zustand Guide: Lightweight State Management for React 19

State management is one of the most critical architectural decisions in React applications. Redux provides power at the cost of boilerplate. Context API works for simple cases but becomes unwieldy at scale. Zustand offers a compelling middle ground: simple, fast, and with minimal boilerplate.

In this guide, you'll master Zustand from fundamentals to production patterns. You'll understand why thousands of developers are adopting it, how to structure stores for large applications, and when to use it versus alternatives.

# Table of Contents

  1. Why Zustand?
  2. Getting Started
  3. Creating Your First Store
  4. Using Stores in Components
  5. State Updates & Immutability
  6. Async Actions
  7. Store Slicing & Splitting
  8. Middleware & Plugins
  9. DevTools Integration
  10. Best Practices
  11. Zustand vs Alternatives
  12. Real-World Examples
  13. FAQ

# Why Zustand? {#why-zustand}

# The Problem with Alternatives

Redux is powerful but verbose:

  • Requires actions, reducers, action creators, selectors
  • High learning curve and setup overhead
  • Many boilerplate files for simple features

Context API is too simple:

  • Performance issues with frequent updates
  • Causes unnecessary re-renders
  • Scales poorly with complex state

Zustand is the Goldilocks solution:

  • Dead simple API (one function call per store)
  • Tiny bundle size (~2KB minified)
  • Zero boilerplate
  • Excellent TypeScript support
  • Built-in DevTools support

# Zustand's Philosophy

typescript
// Zustand: You write what you see
const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

// That's it. No actions, no reducers, no middleware setup.
// You get a hook. Use it anywhere.

# Getting Started {#getting-started}

# Installation

bash
npm install zustand
# or
yarn add zustand
# or
pnpm add zustand

# Peer Dependencies

Zustand requires React 16.8+ (for hooks). No other dependencies.

json
{
  "peerDependencies": {
    "react": ">=16.8"
  }
}

# What You Get

  • A single create function
  • TypeScript support out of the box
  • DevTools middleware included
  • No provider setup needed

# Creating Your First Store {#first-store}

# Basic Store Structure

typescript
import { create } from 'zustand';

// ✅ Simple counter store
interface CounterStore {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

export const useCounterStore = create<CounterStore>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

# Store Anatomy

A Zustand store consists of:

  1. State values — The data you want to share
  2. Actions — Functions that update state via set()
  3. The set function — Updates state (receives previous state)
typescript
interface StoreType {
  // State
  value: string;
  
  // Actions
  updateValue: (newValue: string) => void;
  clear: () => void;
}

export const useStore = create<StoreType>((set) => ({
  // Initial state
  value: '',
  
  // Actions that modify state
  updateValue: (newValue) => set({ value: newValue }),
  clear: () => set({ value: '' }),
}));

# JavaScript Version

javascript
import { create } from 'zustand';

export const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

# Using Stores in Components {#using-stores}

# Basic Hook Usage

typescript
import { useCounterStore } from './store';

export function Counter() {
  // Hook automatically subscribes to store changes
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
}

# Destructuring Multiple Values

typescript
export function Counter() {
  // ✅ Subscribe to multiple values at once
  const { count, increment, decrement } = useCounterStore();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
}

# Selective Subscription (Performance Optimization)

typescript
export function CounterDisplay() {
  // ✅ Only subscribe to count, not actions
  // Component won't re-render if increment/decrement changes
  const count = useCounterStore((state) => state.count);

  return <div>Count: {count}</div>;
}

export function CounterControls() {
  // ✅ Only subscribe to actions, not count
  // Component won't re-render when count changes
  const { increment, decrement } = useCounterStore();

  return (
    <>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </>
  );
}

# Using Multiple Stores

typescript
interface UserStore {
  user: User | null;
  login: (user: User) => void;
  logout: () => void;
}

interface ThemeStore {
  isDark: boolean;
  toggle: () => void;
}

const useUserStore = create<UserStore>((set) => ({
  user: null,
  login: (user) => set({ user }),
  logout: () => set({ user: null }),
}));

const useThemeStore = create<ThemeStore>((set) => ({
  isDark: false,
  toggle: () => set((state) => ({ isDark: !state.isDark })),
}));

export function App() {
  const { user } = useUserStore();
  const { isDark } = useThemeStore();

  return (
    <div className={isDark ? 'dark' : 'light'}>
      <p>Welcome, {user?.name}</p>
    </div>
  );
}

# State Updates & Immutability {#state-updates}

# Immutable Updates

Zustand uses immer internally (optional but recommended). Always return new objects:

typescript
interface TodoStore {
  todos: Todo[];
  addTodo: (todo: Todo) => void;
  updateTodo: (id: string, updates: Partial<Todo>) => void;
  removeTodo: (id: string) => void;
}

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

export const useTodoStore = create<TodoStore>((set) => ({
  todos: [],

  // ✅ CORRECT: Return new array
  addTodo: (todo) => set((state) => ({
    todos: [...state.todos, todo],
  })),

  // ✅ CORRECT: Map to new array
  updateTodo: (id, updates) => set((state) => ({
    todos: state.todos.map((todo) =>
      todo.id === id ? { ...todo, ...updates } : todo
    ),
  })),

  // ✅ CORRECT: Filter to new array
  removeTodo: (id) => set((state) => ({
    todos: state.todos.filter((todo) => todo.id !== id),
  })),
}));

# Using Immer Middleware for Complex Updates

typescript
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

interface StateType {
  nested: {
    deep: {
      value: string;
    };
  };
  updateValue: (newValue: string) => void;
}

// ✅ With immer, you can mutate state directly
export const useStore = create<StateType>()(
  immer((set) => ({
    nested: {
      deep: {
        value: 'initial',
      },
    },

    // ✅ Can mutate draft state directly
    updateValue: (newValue) => set((state) => {
      state.nested.deep.value = newValue;
      // No need to return anything or track immutability
    }),
  }))
);

# Async Actions {#async-actions}

# Basic Async Pattern

typescript
interface ApiStore {
  data: Data[] | null;
  loading: boolean;
  error: Error | null;
  fetchData: () => Promise<void>;
}

interface Data {
  id: string;
  name: string;
}

export const useApiStore = create<ApiStore>((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 instanceof Error ? error : new Error('Unknown error'),
        loading: false,
      });
    }
  },
}));

# Using Async with Component

typescript
import { useEffect } from 'react';
import { useApiStore } from './store';

export function DataList() {
  const { data, loading, error, fetchData } = useApiStore();

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {data?.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

# Complex Async with Optimistic Updates

typescript
interface CartStore {
  items: CartItem[];
  isUpdating: boolean;
  addItem: (item: CartItem) => Promise<void>;
}

interface CartItem {
  id: string;
  name: string;
  quantity: number;
}

export const useCartStore = create<CartStore>((set) => ({
  items: [],
  isUpdating: false,

  addItem: async (item) => {
    // ✅ Optimistic update
    set((state) => ({
      items: [...state.items, item],
      isUpdating: true,
    }));

    try {
      // Send to server
      const response = await fetch('/api/cart', {
        method: 'POST',
        body: JSON.stringify(item),
      });

      if (!response.ok) throw new Error('Failed to add item');

      const savedItem = await response.json();

      // Update with server response if different
      set((state) => ({
        items: state.items.map((i) =>
          i.id === item.id ? savedItem : i
        ),
        isUpdating: false,
      }));
    } catch (error) {
      // Rollback on error
      set((state) => ({
        items: state.items.filter((i) => i.id !== item.id),
        isUpdating: false,
      }));
      throw error;
    }
  },
}));

# Store Slicing & Splitting {#store-slicing}

# One Store vs Multiple Stores

For most apps, one store per feature is better than one big store:

typescript
// ✅ GOOD: Feature-based stores
const useAuthStore = create(...);  // Authentication
const useTodoStore = create(...);  // Todos
const useUIStore = create(...);    // UI state

// ❌ AVOID: One monolithic store
const useAppStore = create((set) => ({
  user: null,
  todos: [],
  theme: 'light',
  notifications: [],
  // ... all state mixed together
}));

# Composing Stores

Combine stores when they share logic:

typescript
// User store
interface User {
  id: string;
  name: string;
}

interface UserStore {
  user: User | null;
  setUser: (user: User) => void;
}

const useUserStore = create<UserStore>((set) => ({
  user: null,
  setUser: (user) => set({ user }),
}));

// Settings store that depends on user
interface SettingsStore {
  userPreferences: Preferences | null;
  updatePreferences: (prefs: Preferences) => void;
}

interface Preferences {
  theme: string;
  language: string;
}

const useSettingsStore = create<SettingsStore>((set) => ({
  userPreferences: null,

  updatePreferences: (prefs) => {
    // Can subscribe to other stores
    const user = useUserStore.getState().user;

    if (!user) {
      console.error('Must be logged in to update preferences');
      return;
    }

    set({ userPreferences: prefs });
  },
}));

# Store Factory Pattern for Reusable Stores

typescript
// Create a store factory for paginated lists
interface PaginatedStore<T> {
  items: T[];
  page: number;
  pageSize: number;
  total: number;
  loading: boolean;
  nextPage: () => void;
  prevPage: () => void;
}

function createPaginatedStore<T>(
  fetchFn: (page: number, pageSize: number) => Promise<{
    items: T[];
    total: number;
  }>
) {
  return create<PaginatedStore<T>>((set) => ({
    items: [],
    page: 1,
    pageSize: 10,
    total: 0,
    loading: false,

    nextPage: async () => {
      set((state) => ({ page: state.page + 1, loading: true }));
      const { items, total } = await fetchFn(
        usePageStore.getState().page,
        usePageStore.getState().pageSize
      );
      set({ items, total, loading: false });
    },

    prevPage: async () => {
      set((state) => ({
        page: Math.max(state.page - 1, 1),
        loading: true,
      }));
      const { items, total } = await fetchFn(
        usePageStore.getState().page,
        usePageStore.getState().pageSize
      );
      set({ items, total, loading: false });
    },
  }));
}

// Usage
const useProductsStore = createPaginatedStore(
  async (page, pageSize) => {
    const response = await fetch(
      `/api/products?page=${page}&pageSize=${pageSize}`
    );
    return response.json();
  }
);

# Middleware & Plugins {#middleware}

# Persist Middleware (LocalStorage)

typescript
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface PreferencesStore {
  theme: 'light' | 'dark';
  language: string;
  setTheme: (theme: 'light' | 'dark') => void;
  setLanguage: (language: string) => void;
}

// ✅ Automatically persist to localStorage
export const usePreferencesStore = create<PreferencesStore>()(
  persist(
    (set) => ({
      theme: 'light',
      language: 'en',
      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
    }),
    {
      name: 'preferences-storage', // key in localStorage
      partialize: (state) => ({
        theme: state.theme,
        language: state.language,
        // Don't persist methods
      }),
    }
  )
);

# Subscribe to Store Changes

typescript
// ✅ Listen to all changes
const unsubscribe = useCounterStore.subscribe(
  (state) => state.count,
  (count) => {
    console.log('Count changed to:', count);
  }
);

// Unsubscribe later
unsubscribe();

// ✅ React to entire state changes
useCounterStore.subscribe(
  (state) => state,
  (newState) => {
    console.log('Store state updated:', newState);
  }
);

# Logging Middleware

typescript
import { create } from 'zustand';

interface LogStore {
  count: number;
  increment: () => void;
}

// Custom logging middleware
const logMiddleware = (f: any) => (set: any, get: any, api: any) =>
  f(
    (...args: any) => {
      console.log('Setting state:', args);
      set(...args);
      console.log('New state:', get());
    },
    get,
    api
  );

export const useLogStore = create<LogStore>(
  logMiddleware((set) => ({
    count: 0,
    increment: () => set((state) => ({ count: state.count + 1 })),
  }))
);

# DevTools Integration {#devtools}

# Enable DevTools for Debugging

typescript
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface CounterStore {
  count: number;
  increment: () => void;
}

export const useCounterStore = create<CounterStore>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
    }),
    {
      name: 'Counter Store', // Display name in DevTools
    }
  )
);

# Install Redux DevTools Extension

  1. Install browser extension (Chrome, Firefox)
  2. Use devtools middleware as shown above
  3. Open Redux DevTools to see:
    • State snapshots
    • Action history
    • Time-travel debugging
    • State diffs

# Best Practices {#best-practices}

# 1. Use Selective Subscriptions

typescript
// ✅ GOOD: Only subscribe to what you need
const count = useCounterStore((state) => state.count);

// ❌ AVOID: Destructuring entire store
const store = useCounterStore(); // Subscribes to everything

# 2. Keep Stores Focused

typescript
// ✅ GOOD: Single responsibility
const useAuthStore = create(...);
const useUIStore = create(...);
const useDataStore = create(...);

// ❌ AVOID: Kitchen sink store
const useAppStore = create((set) => ({
  // auth stuff
  // ui stuff
  // data stuff
  // all mixed together
}));

# 3. Extract Derived State

typescript
// ❌ WRONG: Store derived values
interface CartStoreWrong {
  items: CartItem[];
  total: number; // Derived from items
}

// ✅ CORRECT: Compute derived values
interface CartStoreRight {
  items: CartItem[];
}

export function CartTotal() {
  const items = useCartStore((state) => state.items);
  // Compute total here or in a custom hook
  const total = items.reduce((sum, item) => sum + item.price, 0);

  return <div>Total: ${total}</div>;
}

// Or use a custom hook
function useCartTotal() {
  const items = useCartStore((state) => state.items);
  return items.reduce((sum, item) => sum + item.price, 0);
}

# 4. Use get() for Accessing State in Actions

typescript
interface StoreType {
  user: User | null;
  count: number;
  complexAction: () => void;
}

export const useStore = create<StoreType>((set, get) => ({
  user: null,
  count: 0,

  // ✅ Use get() to access current state
  complexAction: () => {
    const { user, count } = get();
    if (user) {
      set({ count: count + 1 });
    }
  },
}));

# 5. Type Safety Best Practices

typescript
// ✅ GOOD: Explicit types
interface TodoStore {
  todos: Todo[];
  addTodo: (todo: Todo) => void;
  removeTodo: (id: string) => void;
}

export const useTodoStore = create<TodoStore>((set) => ({
  todos: [],
  addTodo: (todo) => set((state) => ({ todos: [...state.todos, todo] })),
  removeTodo: (id) => set((state) => ({
    todos: state.todos.filter((t) => t.id !== id),
  })),
}));

// ❌ AVOID: Implicit types (loses type safety)
export const useTodoStore = create((set) => ({
  todos: [],
  addTodo: (todo: any) => set((state: any) => ({ todos: [...state.todos, todo] })),
}));

# Zustand vs Alternatives {#vs-alternatives}

Aspect Zustand Redux Context Recoil
Bundle Size 2KB 12KB Built-in 40KB
Learning Curve Very Easy Steep Easy Moderate
Boilerplate None High Medium Medium
DevTools Built-in Excellent No Limited
Async Built-in Requires middleware Use hooks Built-in
Performance Excellent Good Can cause re-renders Good
Best For Most apps Complex enterprise Simple state Atom-based

# When to Use Zustand

Use Zustand when:

  • You want minimal boilerplate
  • Building most modern React apps
  • Combining multiple features
  • Need good TypeScript support
  • Want zero provider setup

Consider alternatives when:

  • Extremely large teams (Redux's structure helps)
  • Need atom-based reactivity (Recoil)
  • Only need simple local state (useState)

# Real-World Examples {#examples}

# Example 1: Authentication Store

typescript
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';

interface User {
  id: string;
  email: string;
  name: string;
  role: 'user' | 'admin';
}

interface AuthStore {
  user: User | null;
  token: string | null;
  isLoading: boolean;
  error: string | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  isAuthenticated: boolean;
}

export const useAuthStore = create<AuthStore>()(
  devtools(
    persist(
      (set, get) => ({
        user: null,
        token: null,
        isLoading: false,
        error: null,
        isAuthenticated: false,

        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('Login failed');
            }

            const { user, token } = await response.json();

            set({
              user,
              token,
              isAuthenticated: true,
              isLoading: false,
            });
          } catch (error) {
            set({
              error: error instanceof Error ? error.message : 'Unknown error',
              isLoading: false,
            });
          }
        },

        logout: () => {
          set({
            user: null,
            token: null,
            isAuthenticated: false,
          });
        },
      }),
      {
        name: 'auth-storage',
        partialize: (state) => ({
          user: state.user,
          token: state.token,
          isAuthenticated: state.isAuthenticated,
        }),
      }
    ),
    { name: 'Auth Store' }
  )
);

// Usage
export function LoginForm() {
  const { login, isLoading, error } = useAuthStore();

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    await login(
      formData.get('email') as string,
      formData.get('password') as string
    );
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" placeholder="Email" />
      <input name="password" type="password" placeholder="Password" />
      <button disabled={isLoading}>
        {isLoading ? 'Logging in...' : 'Login'}
      </button>
      {error && <p className="error">{error}</p>}
    </form>
  );
}

# Example 2: Shopping Cart Store

typescript
interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartStore {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clear: () => void;
  itemCount: () => number;
  total: () => number;
}

export const useCartStore = create<CartStore>((set, get) => ({
  items: [],

  addItem: (item) => set((state) => {
    const existing = state.items.find((i) => i.id === item.id);
    if (existing) {
      return {
        items: state.items.map((i) =>
          i.id === item.id
            ? { ...i, quantity: i.quantity + item.quantity }
            : i
        ),
      };
    }
    return { items: [...state.items, item] };
  }),

  removeItem: (id) => set((state) => ({
    items: state.items.filter((i) => i.id !== id),
  })),

  updateQuantity: (id, quantity) => set((state) => ({
    items: state.items.map((i) =>
      i.id === id ? { ...i, quantity } : i
    ).filter((i) => i.quantity > 0),
  })),

  clear: () => set({ items: [] }),

  itemCount: () => get().items.reduce((sum, item) => sum + item.quantity, 0),

  total: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}));

// Usage
export function CartSummary() {
  const itemCount = useCartStore((state) => state.itemCount());
  const total = useCartStore((state) => state.total());

  return (
    <div className="cart-summary">
      <p>Items: {itemCount}</p>
      <p>Total: ${total.toFixed(2)}</p>
    </div>
  );
}

# FAQ {#faq}

Q: Do I need a provider like Redux?

A: No! Zustand requires zero setup. Just create a store and use it directly in components.

Q: Is Zustand good for server state management?

A: Not recommended. Use React Query or SWR for server state. Zustand excels at client/UI state.

Q: How do I handle async operations?

A: Define async functions in your store actions. They automatically update state via set(). No middleware needed.

Q: Can I use Zustand with TypeScript?

A: Yes, full TypeScript support. Just define store type and actions get proper types.

Q: Should I have one store or multiple?

A: Multiple stores per feature (auth, ui, data, etc.). Easier to maintain than one monolithic store.

Q: How do I prevent unnecessary re-renders?

A: Use selective subscriptions with arrow functions: const count = useStore((state) => state.count). Only subscribes to that value.

Q: Can I use Zustand with Next.js?

A: Yes! Works great with Next.js App Router and Pages Router. Just create stores in separate files and import them.

Q: What's the difference between set() and direct mutation?

A: Always use set() for updates. Direct mutations won't trigger re-renders. Return new objects for immutability.


Ready to simplify your state management? Zustand's minimal API makes it perfect for projects of any size. Start with a single store and grow as your app evolves.

Next Steps: Explore React Query for server state, custom hooks for component logic, and TypeScript patterns for type safety.

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT