Zustand State Management: Patterns for Complex Apps

Master Zustand state management for React apps. Covers slices, middleware, persistence, devtools, TypeScript patterns, and integration with TanStack Query for complex application state.

E
ECOSIRE Research and Development Team
|March 19, 202610 min read2.2k Words|

Zustand State Management: Patterns for Complex Apps

React's built-in state primitives (useState, useReducer, useContext) work well for local and shared UI state, but they hit friction at scale: context providers re-render the entire tree on every state change, useReducer requires verbose action boilerplate, and prop-drilling becomes unmanageable. Redux solves this but at the cost of significant boilerplate and mental overhead.

Zustand occupies a perfect middle ground: 2.9KB, no providers, no boilerplate, TypeScript-first, and compatible with React DevTools. It excels at client-side application state — shopping carts, sidebar state, multi-step form data, UI preferences — while TanStack Query handles server state. This guide covers Zustand v5 patterns for production applications.

Key Takeaways

  • Zustand is for client-only state; use TanStack Query for server state — do not overlap their responsibilities
  • No Provider wrapper needed — the store is module-level singleton, accessible anywhere
  • Use slices pattern for large stores: compose multiple slice creators into one create() call
  • immer middleware enables direct mutation syntax inside actions (no spread operators)
  • persist middleware with localStorage or sessionStorage for cross-session state
  • devtools middleware integrates with Redux DevTools Extension for time-travel debugging
  • Selectors with useShallow prevent unnecessary re-renders from shallow-equal objects
  • Never store server data in Zustand — invalidation becomes your problem

Basic Store

// src/lib/stores/ui.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface UiState {
  // State
  sidebarOpen: boolean;
  theme: 'light' | 'dark' | 'system';
  commandPaletteOpen: boolean;
  activeModal: string | null;

  // Actions
  toggleSidebar: () => void;
  setSidebarOpen: (open: boolean) => void;
  setTheme: (theme: UiState['theme']) => void;
  openModal: (modalId: string) => void;
  closeModal: () => void;
  toggleCommandPalette: () => void;
}

export const useUiStore = create<UiState>()(
  devtools(
    (set) => ({
      sidebarOpen: true,
      theme: 'system',
      commandPaletteOpen: false,
      activeModal: null,

      toggleSidebar: () =>
        set((state) => ({ sidebarOpen: !state.sidebarOpen }), false, 'toggleSidebar'),

      setSidebarOpen: (open) =>
        set({ sidebarOpen: open }, false, 'setSidebarOpen'),

      setTheme: (theme) =>
        set({ theme }, false, 'setTheme'),

      openModal: (modalId) =>
        set({ activeModal: modalId }, false, 'openModal'),

      closeModal: () =>
        set({ activeModal: null }, false, 'closeModal'),

      toggleCommandPalette: () =>
        set(
          (state) => ({ commandPaletteOpen: !state.commandPaletteOpen }),
          false,
          'toggleCommandPalette'
        ),
    }),
    { name: 'ui-store' }
  )
);
// Usage in components — select only what you need
function Sidebar() {
  const isOpen = useUiStore((state) => state.sidebarOpen);
  const toggle = useUiStore((state) => state.toggleSidebar);

  return (
    <aside className={cn('sidebar', isOpen && 'sidebar--open')}>
      <button onClick={toggle}>Toggle</button>
    </aside>
  );
}

Cart Store with Persistence

// src/lib/cart.ts
import { create } from 'zustand';
import { persist, createJSONStorage, devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

interface CartItem {
  id: string;
  productId: string;
  name: string;
  price: number;
  quantity: number;
  licenseType: 'one-time' | 'subscription';
}

interface CartState {
  items: CartItem[];
  couponCode: string | null;
  couponDiscount: number;

  // Computed (derived state — use selectors, not stored values)
  // subtotal, total, itemCount — computed in selectors

  // Actions
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (productId: string) => void;
  updateQuantity: (productId: string, quantity: number) => void;
  applyCoupon: (code: string, discount: number) => void;
  removeCoupon: () => void;
  clearCart: () => void;
}

export const useCart = create<CartState>()(
  devtools(
    persist(
      immer((set) => ({
        items: [],
        couponCode: null,
        couponDiscount: 0,

        addItem: (newItem) =>
          set((state) => {
            const existing = state.items.find((i) => i.productId === newItem.productId);
            if (existing) {
              existing.quantity += 1; // Immer allows direct mutation
            } else {
              state.items.push({ ...newItem, quantity: 1 });
            }
          }),

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

        updateQuantity: (productId, quantity) =>
          set((state) => {
            if (quantity <= 0) {
              state.items = state.items.filter((i) => i.productId !== productId);
            } else {
              const item = state.items.find((i) => i.productId === productId);
              if (item) item.quantity = quantity;
            }
          }),

        applyCoupon: (code, discount) =>
          set((state) => {
            state.couponCode = code;
            state.couponDiscount = discount;
          }),

        removeCoupon: () =>
          set((state) => {
            state.couponCode = null;
            state.couponDiscount = 0;
          }),

        clearCart: () =>
          set((state) => {
            state.items = [];
            state.couponCode = null;
            state.couponDiscount = 0;
          }),
      })),
      {
        name: 'ecosire-cart',
        storage: createJSONStorage(() => localStorage),
        partialize: (state) => ({
          // Only persist items and coupon, not derived state
          items: state.items,
          couponCode: state.couponCode,
          couponDiscount: state.couponDiscount,
        }),
      }
    ),
    { name: 'cart-store' }
  )
);

// Selectors — computed from state, not stored
export const selectSubtotal = (state: CartState) =>
  state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);

export const selectTotal = (state: CartState) => {
  const subtotal = selectSubtotal(state);
  return subtotal * (1 - state.couponDiscount);
};

export const selectItemCount = (state: CartState) =>
  state.items.reduce((sum, item) => sum + item.quantity, 0);

Slice Pattern for Large Stores

For complex stores, split into slices and compose:

// src/lib/stores/slices/filters.slice.ts
import type { StateCreator } from 'zustand';

export interface FiltersSlice {
  search: string;
  status: string;
  page: number;
  sortField: string;
  sortDirection: 'asc' | 'desc';

  setSearch: (search: string) => void;
  setStatus: (status: string) => void;
  setPage: (page: number) => void;
  setSort: (field: string, direction: 'asc' | 'desc') => void;
  resetFilters: () => void;
}

const initialFilters = {
  search: '',
  status: 'all',
  page: 1,
  sortField: 'createdAt',
  sortDirection: 'desc' as const,
};

export const createFiltersSlice: StateCreator<FiltersSlice> = (set) => ({
  ...initialFilters,

  setSearch: (search) => set({ search, page: 1 }), // Reset page on search
  setStatus: (status) => set({ status, page: 1 }),
  setPage: (page) => set({ page }),
  setSort: (field, direction) => set({ sortField: field, sortDirection: direction }),
  resetFilters: () => set(initialFilters),
});
// src/lib/stores/slices/selection.slice.ts
import type { StateCreator } from 'zustand';

export interface SelectionSlice {
  selectedIds: Set<string>;
  selectAll: boolean;

  toggleSelection: (id: string) => void;
  selectAllItems: (ids: string[]) => void;
  clearSelection: () => void;
}

export const createSelectionSlice: StateCreator<SelectionSlice> = (set) => ({
  selectedIds: new Set(),
  selectAll: false,

  toggleSelection: (id) =>
    set((state) => {
      const next = new Set(state.selectedIds);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return { selectedIds: next, selectAll: false };
    }),

  selectAllItems: (ids) =>
    set({ selectedIds: new Set(ids), selectAll: true }),

  clearSelection: () =>
    set({ selectedIds: new Set(), selectAll: false }),
});
// src/lib/stores/contacts-table.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { createFiltersSlice, type FiltersSlice } from './slices/filters.slice';
import { createSelectionSlice, type SelectionSlice } from './slices/selection.slice';

type ContactsTableStore = FiltersSlice & SelectionSlice;

export const useContactsTableStore = create<ContactsTableStore>()(
  devtools(
    (...args) => ({
      ...createFiltersSlice(...args),
      ...createSelectionSlice(...args),
    }),
    { name: 'contacts-table-store' }
  )
);

Preventing Unnecessary Re-renders

Zustand re-renders the component when the selected value changes (by reference for objects).

// PROBLEM: new object created on every render → infinite re-renders
const { search, page } = useContactsTableStore((state) => ({
  search: state.search,
  page: state.page,
}));

// SOLUTION 1: Select primitives individually
const search = useContactsTableStore((state) => state.search);
const page   = useContactsTableStore((state) => state.page);

// SOLUTION 2: Use useShallow for object selections
import { useShallow } from 'zustand/react/shallow';

const { search, page } = useContactsTableStore(
  useShallow((state) => ({ search: state.search, page: state.page }))
);

// SOLUTION 3: Select an action (actions never change reference)
const setSearch = useContactsTableStore((state) => state.setSearch);

Zustand + TanStack Query: Clear Separation

The key discipline: Zustand for client state, TanStack Query for server state — never overlap.

// WRONG: storing server data in Zustand
const useContactsStore = create(() => ({
  contacts: [],          // Server data in Zustand — invalidation nightmare
  fetchContacts: async () => { /* fetch and set */ },
}));

// CORRECT: TanStack Query for server data, Zustand for UI state
// TanStack Query
const { data: contacts } = useContacts({ page, limit, search });

// Zustand — only UI-specific state
const { search, page, setSearch, setPage } = useContactsTableStore();

The query key reacts to Zustand state changes, so filtering/sorting just works:

function ContactsPage() {
  // UI state from Zustand
  const search = useContactsTableStore((s) => s.search);
  const page   = useContactsTableStore((s) => s.page);
  const status = useContactsTableStore((s) => s.status);

  // Server state from TanStack Query, driven by Zustand state
  const { data, isLoading } = useContacts({ search, page, status, limit: 20 });

  return <ContactsTable data={data?.data ?? []} loading={isLoading} />;
}

Multi-Step Form Store

// src/lib/stores/onboarding.store.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

interface OnboardingData {
  company: { name: string; size: string; industry: string };
  contact: { name: string; email: string; phone: string };
  requirements: { erp: string; modules: string[]; budget: string };
}

interface OnboardingStore {
  currentStep: number;
  completed: Set<number>;
  data: Partial<OnboardingData>;

  setStep: (step: number) => void;
  updateStepData: <K extends keyof OnboardingData>(
    step: K,
    data: OnboardingData[K]
  ) => void;
  markStepComplete: (step: number) => void;
  reset: () => void;
}

export const useOnboardingStore = create<OnboardingStore>()(
  persist(
    (set) => ({
      currentStep: 0,
      completed: new Set(),
      data: {},

      setStep: (step) => set({ currentStep: step }),

      updateStepData: (key, stepData) =>
        set((state) => ({
          data: { ...state.data, [key]: stepData },
        })),

      markStepComplete: (step) =>
        set((state) => ({
          completed: new Set([...state.completed, step]),
        })),

      reset: () => set({ currentStep: 0, completed: new Set(), data: {} }),
    }),
    {
      name: 'onboarding-progress',
      storage: createJSONStorage(() => sessionStorage),
      // Serialize Set properly
      partialize: (state) => ({
        ...state,
        completed: Array.from(state.completed),
      }),
      merge: (persisted: any, current) => ({
        ...current,
        ...persisted,
        completed: new Set(persisted.completed ?? []),
      }),
    }
  )
);

Testing Zustand Stores

// src/lib/stores/__tests__/cart.store.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useCart, selectSubtotal, selectTotal } from '../cart';

describe('Cart Store', () => {
  beforeEach(() => {
    // Reset store state between tests
    useCart.setState({
      items: [],
      couponCode: null,
      couponDiscount: 0,
    });
  });

  it('adds items to the cart', () => {
    const { addItem } = useCart.getState();
    addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });

    const { items } = useCart.getState();
    expect(items).toHaveLength(1);
    expect(items[0].quantity).toBe(1);
  });

  it('increments quantity when same product added again', () => {
    const { addItem } = useCart.getState();
    addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });
    addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });

    const { items } = useCart.getState();
    expect(items).toHaveLength(1);
    expect(items[0].quantity).toBe(2);
  });

  it('calculates subtotal correctly', () => {
    useCart.setState({
      items: [
        { id: '1', productId: 'p1', name: 'A', price: 100, quantity: 2, licenseType: 'one-time' },
        { id: '2', productId: 'p2', name: 'B', price: 50,  quantity: 1, licenseType: 'one-time' },
      ],
    });

    expect(selectSubtotal(useCart.getState())).toBe(250);
  });

  it('applies coupon discount', () => {
    useCart.setState({
      items: [{ id: '1', productId: 'p1', name: 'A', price: 100, quantity: 1, licenseType: 'one-time' }],
      couponCode: 'SAVE20',
      couponDiscount: 0.2,
    });

    expect(selectTotal(useCart.getState())).toBe(80);
  });
});

Frequently Asked Questions

Do I still need Context API with Zustand?

Rarely. Zustand's module-level singleton replaces most Context API use cases. The main remaining use case for Context is dependency injection — passing a specific store instance to a subtree (useful for testing or when you need multiple independent store instances). For that, Zustand provides createStore() + useStore() which work with a React context for the store instance.

How does Zustand compare to Redux Toolkit?

Redux Toolkit has more structure (slices, reducers, actions) and better DevTools integration for complex time-travel debugging. Zustand has less boilerplate, a smaller bundle (2.9KB vs ~30KB for Redux + RTK), and does not require a Provider. For most applications, Zustand is simpler and sufficient. Choose Redux Toolkit if you have a large team that benefits from enforced patterns or need advanced DevTools features.

How do I use Zustand with Next.js App Router SSR?

Zustand stores are module-level singletons — safe for client components but cannot be used in Server Components (no browser APIs, no window). Initialize stores in 'use client' components only. For SSR hydration of initial data, use TanStack Query's HydrationBoundary for server data. For client-only state (cart, UI preferences) that needs to persist across navigation, use persist middleware with localStorage.

What is the immer middleware and when should I use it?

immer middleware wraps Zustand's set function with Immer, allowing you to write mutating code in actions (state.items.push(item)) that is actually immutable under the hood. Use it when you have nested state updates that are awkward with spread syntax, like updating a deeply nested property or working with arrays. It adds ~6KB to your bundle — worth it for complex stores, unnecessary for simple ones.

How do I avoid stale closures in Zustand actions?

Zustand passes the current state to the set callback, so always use the functional form when reading state inside an action: set((state) => ({ count: state.count + 1 })) instead of capturing state.count from the closure. For async actions that read state after an await, call getState() at the point of use rather than capturing state in the closure before the await.


Next Steps

Zustand's minimal footprint and composable slice pattern make it the right tool for client state in React applications of any size. Paired with TanStack Query for server state, you have a clean, scalable state management architecture with no boilerplate overhead.

ECOSIRE builds React applications with Zustand + TanStack Query as the standard state management stack — serving complex admin panels, customer portals, and e-commerce flows. Explore our frontend engineering services to learn how we architect scalable React applications.

E

Written by

ECOSIRE Research and Development Team

Building enterprise-grade digital products at ECOSIRE. Sharing insights on Odoo integrations, e-commerce automation, and AI-powered business solutions.

Chat on WhatsApp