Skip to content
All essays
WebMarch 25, 202422 min

Zustand State Management Guide

Master Zustand: lightweight, flexible state management for React with minimal boilerplate

Ü
Ümit Uz
Mobile & Full Stack Developer

Zustand is a lightweight, fast, and flexible state management solution for React. Let's master it from basics to advanced patterns.

Why Zustand?

  • Minimal boilerplate: No actions, reducers, or dispatch
  • Simple API: Just hooks and stores
  • TypeScript friendly: Built-in types support
  • No providers needed: Use stores anywhere
  • Tiny bundle: ~1KB minified
  • DevTools: Built-in middleware

Installation

bash
npm install zustand

Basic Store

Create Store

typescript
// store/useCounterStore.ts
import { create } from 'zustand';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
  incrementBy: (amount: number) => void;
}

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

Use in Component

typescript
// components/Counter.tsx
import { useCounterStore } from '../store/useCounterStore';

export function Counter() {
  const { count, increment, decrement, reset } = useCounterStore();

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

Selecting Specific State

Select Single Value

typescript
// Only re-renders when count changes
const count = useCounterStore((state) => state.count);

// Only re-renders when increment changes
const increment = useCounterStore((state) => state.increment);

Select Multiple Values

typescript
// Re-renders when either count or name changes
const { count, name } = useCounterStore(
  (state) => ({ count: state.count, name: state.name })
);

// Or use shallow comparison for objects
import { shallow } from 'zustand/shallow';

const { count, name } = useCounterStore(
  (state) => ({ count: state.count, name: state.name }),
  shallow
);

Async Actions

Simple Async

typescript
// store/useUserStore.ts
import { create } from 'zustand';

interface UserState {
  user: null | { id: number; name: string; email: string };
  loading: boolean;
  error: null | string;
  fetchUser: (id: number) => Promise<void>;
}

export const useUserStore = create<UserState>((set) => ({
  user: null,
  loading: false,
  error: null,

  fetchUser: async (id) => {
    set({ loading: true, error: null });

    try {
      const response = await fetch(`https://api.example.com/users/${id}`);
      const user = await response.json();
      set({ user, loading: false });
    } catch (error) {
      set({
        error: error instanceof Error ? error.message : 'Failed to fetch user',
        loading: false,
      });
    }
  },
}));

With Loading States

typescript
// components/UserProfile.tsx
import { useEffect } from 'react';
import { useUserStore } from '../store/useUserStore';

export function UserProfile({ userId }: { userId: number }) {
  const { user, loading, error, fetchUser } = useUserStore();

  useEffect(() => {
    fetchUser(userId);
  }, [userId, fetchUser]);

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

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

Multiple Stores

Separate Stores by Domain

typescript
// store/useAuthStore.ts
import { create } from 'zustand';

export const useAuthStore = create((set) => ({
  user: null,
  token: null,
  login: async (credentials) => {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials),
    });
    const { user, token } = await response.json();
    set({ user, token });
  },
  logout: () => set({ user: null, token: null }),
}));

// store/useCartStore.ts
export const useCartStore = create((set) => ({
  items: [],
  total: 0,
  addItem: (item) => set((state) => ({
    items: [...state.items, item],
    total: state.total + item.price,
  })),
  removeItem: (itemId) => set((state) => ({
    items: state.items.filter(item => item.id !== itemId),
  })),
  clear: () => set({ items: [], total: 0 }),
}));

Use Multiple Stores

typescript
import { useAuthStore } from '../store/useAuthStore';
import { useCartStore } from '../store/useCartStore';

export function Header() {
  const user = useAuthStore((state) => state.user);
  const total = useCartStore((state) => state.total);

  return (
    <header>
      <span>Welcome, {user?.name}</span>
      <span>Cart: {total}</span>
    </header>
  );
}

Middleware

DevTools

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

export const useCounterStore = create(
  devtools<CounterState>((set) => ({
    count: 0,
    increment: () => set((state) => ({ count: state.count + 1 })),
  }))
);

Persist (LocalStorage)

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

export const useAuthStore = create(
  persist<AuthState>(
    (set) => ({
      user: null,
      token: null,
      setAuth: (user, token) => set({ user, token }),
      clearAuth: () => set({ user: null, token: null }),
    }),
    {
      name: 'auth-storage', // unique key
    }
  )
);

// Usage with partial persist
export const useUserStore = create(
  persist<UserState>(
    (set) => ({
      user: null,
      preferences: {},
      setUser: (user) => set({ user }),
      setPreferences: (preferences) => set({ preferences }),
    }),
    {
      name: 'user-storage',
      partialize: (state) =>
        Object.fromEntries(
          Object.entries(state).filter(([key]) => key === 'preferences')
        ),
    }
  )
);

Combine Middlewares

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

export const useStore = create(
  devtools(
    persist(
      (set) => ({
        // ...store logic
      }),
      { name: 'app-storage' }
    )
  )
);

Advanced Patterns

Store with Actions

typescript
interface BearState {
  bears: number;
  increasePopulation: () => void;
  removeAllBears: () => void;
}

export const useBearStore = create<BearState>((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}));

Store with Computed Values

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

interface CartState {
  items: Array<{ id: number; price: number; quantity: number }>;
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: number) => void;
  updateQuantity: (id: number, quantity: number) => void;
  clear: () => void;
}

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

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

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

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

        clear: () => set({ items: [] }),
      }),
      { name: 'cart-storage' }
    )
  )
);

// Computed selector outside store
export const useCartTotal = () =>
  useCartStore((state) =>
    state.items.reduce((total, item) => total + item.price * item.quantity, 0)
  );

Actions with setState

typescript
export const useStore = create((set) => ({
  // State
  items: [],
  filter: 'all',

  // Simple actions
  setFilter: (filter: string) => set({ filter }),

  // Actions that use current state
  addItem: (item: Item) =>
    set((state) => ({
      items: [...state.items, item],
    })),

  // Complex actions
  addItems: (items: Item[]) =>
    set((state) => ({
      items: [...state.items, ...items],
    })),

  // Actions that need multiple states
  updateItem: (id: number, updates: Partial<Item>) =>
    set((state) => ({
      items: state.items.map((item) =>
        item.id === id ? { ...item, ...updates } : item
      ),
    })),
}));

TypeScript Best Practices

Full Type Safety

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

// Define state and actions separately
interface TodoState {
  todos: Array<{
    id: number;
    title: string;
    completed: boolean;
  }>;
  filter: 'all' | 'active' | 'completed';
}

interface TodoActions {
  addTodo: (title: string) => void;
  toggleTodo: (id: number) => void;
  deleteTodo: (id: number) => void;
  setFilter: (filter: TodoState['filter']) => void;
}

type TodoStore = TodoState & TodoActions;

// Type-safe store creation
export const useTodoStore = create<TodoStore>()(
  devtools(
    persist(
      (set, get) => ({
        // State
        todos: [],
        filter: 'all',

        // Actions
        addTodo: (title) => {
          const newTodo = {
            id: Date.now(),
            title,
            completed: false,
          };
          set((state) => ({
            todos: [...state.todos, newTodo],
          }));
        },

        toggleTodo: (id) => {
          set((state) => ({
            todos: state.todos.map((todo) =>
              todo.id === id
                ? { ...todo, completed: !todo.completed }
                : todo
            ),
          }));
        },

        deleteTodo: (id) => {
          set((state) => ({
            todos: state.todos.filter((todo) => todo.id !== id),
          }));
        },

        setFilter: (filter) => set({ filter }),
      }),
      { name: 'todo-storage' }
    )
  )
);

// Type-safe selectors
export const useFilteredTodos = () =>
  useTodoStore((state) => {
    const { todos, filter } = state;
    switch (filter) {
      case 'active':
        return todos.filter((todo) => !todo.completed);
      case 'completed':
        return todos.filter((todo) => todo.completed);
      default:
        return todos;
    }
  });

Testing

Test Store

typescript
// tests/store/useCounterStore.test.ts
import { act, renderHook } from '@testing-library/react';
import { useCounterStore } from '../store/useCounterStore';

describe('useCounterStore', () => {
  it('should increment count', () => {
    const { result } = renderHook(() => useCounterStore());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('should increment by amount', () => {
    const { result } = renderHook(() => useCounterStore());

    act(() => {
      result.current.incrementBy(5);
    });

    expect(result.current.count).toBe(5);
  });
});

Performance Optimization

Prevent Unnecessary Re-renders

typescript
// ❌ BAD: Re-renders on any state change
const { count, name, age } = useStore();

// ✅ GOOD: Only re-renders when count changes
const count = useStore((state) => state.count);

// ✅ GOOD: Re-renders only when count or name changes
const { count, name } = useStore(
  (state) => ({ count: state.count, name: state.name }),
  shallow
);

Use Zustand/shallow for Objects

typescript
import { shallow } from 'zustand/shallow';

// Only re-renders if name or age changes
const { name, age } = useStore(
  (state) => ({ name: state.name, age: state.age }),
  shallow
);

Selector Functions

typescript
// Create reusable selectors
const selectCount = (state: CounterState) => state.count;
const selectIncrement = (state: CounterState) => state.increment;

// Use in components
const count = useCounterStore(selectCount);
const increment = useCounterStore(selectIncrement);

Server-Side Rendering

Hydration Pattern

typescript
import { create } from 'zustand';

export const useStore = create(() => ({
  // ...store logic
}));

// Hydrate store on client
export const hydrateStore = (initialState: AppState) => {
  useStore.setState(initialState);
};

// In SSR setup
export function getServerState() {
  return useStore.getState();
}

// In client entry
import { hydrateStore } from './store';

hydrateStore(window.__INITIAL_STATE__);

Best Practices

1. Organize Stores by Feature

typescript
// store/
//   useAuthStore.ts
//   useCartStore.ts
//   useUserStore.ts
//   useProductStore.ts

2. Use TypeScript

Always type your stores:

typescript
interface MyState {
  // ...
}

export const useMyStore = create<MyState>((set) => ({
  // ...
}));

3. Select Specific State

Avoid selecting entire state:

typescript
// ❌ BAD
const state = useStore();

// ✅ GOOD
const value = useStore((state) => state.value);

4. Use Middleware

Add DevTools and persist:

typescript
export const useStore = create(
  devtools(
    persist(
      (set) => ({
        // ...
      }),
      { name: 'my-storage' }
    )
  )
);

5. Keep Stores Simple

Don't over-engineer:

typescript
// ✅ GOOD: Simple and clear
export const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

// ❌ BAD: Over-complicated
export const useStore = create((set, get, api) => ({
  count: 0,
  increment: () => {
    const state = get();
    const newState = { count: state.count + 1 };
    set(newState);
    api.getState().someOtherAction();
    // ...more complexity
  },
}));

Conclusion

Zustand provides a simple, powerful alternative to Redux. Use it for:

  • Small to medium apps
  • When you want minimal boilerplate
  • When you need TypeScript support
  • When you prefer hooks over providers

Master the basics, use TypeScript, leverage middleware, and follow best practices for optimal results.

Enjoyed this essay? Share it.

Related essays

Next essay
Redux Toolkit Complete Guide