On this page

Global State with Zustand: Simple and Scalable Stores

14 min read TextCh. 5 — Production

Why Zustand?

While the Context API works well for low-frequency global values (theme, locale, auth), it has performance limitations for frequently-updated shared state: every consumer re-renders whenever any part of the context changes.

Zustand is a minimal, fast, and scalable state management library that solves this:

  • No Provider boilerplate
  • Subscription to slices of state (surgical re-renders)
  • Built-in middleware for devtools, persistence, and immer
  • Compatible with React Concurrent Mode
  • Works outside of React components (in services, utilities)
  • Tiny bundle: ~1kB gzipped

Installation

npm install zustand

Creating a Store

A Zustand store is created with the create function. The store holds both state and actions:

import { create } from 'zustand';

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

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 }),
  incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
}));

export { useCounterStore };

The set function accepts either a partial state object or a function that receives the current state and returns a partial state object.

Using the Store in Components

function CounterDisplay() {
  // Only re-renders when `count` changes
  const count = useCounterStore((s) => s.count);
  return <p>Count: {count}</p>;
}

function CounterControls() {
  // Actions have stable references — this component rarely re-renders
  const increment = useCounterStore((s) => s.increment);
  const decrement = useCounterStore((s) => s.decrement);
  const reset = useCounterStore((s) => s.reset);

  return (
    <div>
      <button type="button" onClick={decrement}>−</button>
      <button type="button" onClick={reset}>Reset</button>
      <button type="button" onClick={increment}>+</button>
    </div>
  );
}

Note that CounterDisplay and CounterControls are separate components. Each subscribes only to what it needs.

Async Actions

Zustand actions can be async — just use async/await inside the action:

interface UserStore {
  user: User | null;
  isLoading: boolean;
  error: string | null;
  fetchUser: (id: string) => Promise<void>;
  clearUser: () => void;
}

const useUserStore = create<UserStore>()((set) => ({
  user: null,
  isLoading: false,
  error: null,

  fetchUser: async (id) => {
    set({ isLoading: true, error: null });
    try {
      const res = await fetch(`/api/users/${id}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const user = await res.json() as User;
      set({ user, isLoading: false });
    } catch (err) {
      const message = err instanceof Error ? err.message : 'Unknown error';
      set({ error: message, isLoading: false });
    }
  },

  clearUser: () => set({ user: null, error: null }),
}));

Middleware: persist

The persist middleware automatically saves and restores store state to/from localStorage:

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

interface SettingsStore {
  theme: 'light' | 'dark';
  language: string;
  notifications: boolean;
  setTheme: (theme: 'light' | 'dark') => void;
  setLanguage: (lang: string) => void;
}

const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: 'light',
      language: 'en',
      notifications: true,
      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
    }),
    {
      name: 'app-settings',                    // localStorage key
      storage: createJSONStorage(() => localStorage),
      // Only persist specific fields
      partialize: (state) => ({
        theme: state.theme,
        language: state.language,
      }),
    }
  )
);

Middleware: devtools

The devtools middleware integrates with Redux DevTools browser extension for time-travel debugging:

import { devtools } from 'zustand/middleware';

const useProductStore = create<ProductStore>()(
  devtools(
    (set) => ({
      products: [],
      selectedId: null,
      setProducts: (products) => set({ products }, false, 'setProducts'),
      selectProduct: (id) => set({ selectedId: id }, false, 'selectProduct'),
    }),
    { name: 'ProductStore' }
  )
);

Passing action names as the third argument to set makes debugging much easier in the DevTools.

Slices Pattern

For large stores, organize state into slices and combine them:

import { create, type StateCreator } from 'zustand';

interface AuthSlice {
  user: User | null;
  token: string | null;
  setUser: (user: User, token: string) => void;
  logout: () => void;
}

interface UISlice {
  sidebarOpen: boolean;
  toggleSidebar: () => void;
  notification: string | null;
  showNotification: (msg: string) => void;
}

type AppStore = AuthSlice & UISlice;

const createAuthSlice: StateCreator<AppStore, [], [], AuthSlice> = (set) => ({
  user: null,
  token: null,
  setUser: (user, token) => set({ user, token }),
  logout: () => set({ user: null, token: null }),
});

const createUISlice: StateCreator<AppStore, [], [], UISlice> = (set) => ({
  sidebarOpen: false,
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
  notification: null,
  showNotification: (msg) => set({ notification: msg }),
});

const useAppStore = create<AppStore>()((...args) => ({
  ...createAuthSlice(...args),
  ...createUISlice(...args),
}));

Reading Store State Outside React

Zustand stores can be read and updated outside React components — useful in service layers, utilities, and event handlers:

// Access state imperatively
const currentUser = useUserStore.getState().user;

// Update state imperatively
useUserStore.getState().clearUser();

// Subscribe to state changes outside React
const unsubscribe = useUserStore.subscribe(
  (state) => state.user,
  (user) => {
    if (!user) {
      // User logged out — redirect
      window.location.href = '/login';
    }
  }
);

// Cleanup
unsubscribe();

Combining Zustand with TanStack Query

Zustand is designed for client state (UI state, user preferences, temporary selections). TanStack Query handles server state (data from APIs). They complement each other perfectly:

function ProductBrowser() {
  // Server state — managed by TanStack Query
  const { data: products } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
  });

  // Client state — managed by Zustand
  const selectedId = useProductStore((s) => s.selectedId);
  const selectProduct = useProductStore((s) => s.selectProduct);

  const selectedProduct = products?.find((p) => p.id === selectedId);

  return (
    <div className="product-browser">
      <ProductList
        products={products ?? []}
        selectedId={selectedId}
        onSelect={selectProduct}
      />
      {selectedProduct && <ProductDetail product={selectedProduct} />}
    </div>
  );
}

In the next lesson, you will learn advanced performance techniques including the React Compiler, code splitting, and profiling with DevTools.

Use selectors to subscribe to slices of state
Pass a selector function to the store hook to subscribe only to the part of state your component needs: `useCartStore((s) => s.items)`. This way the component only re-renders when `items` changes, not when any other part of the store changes.
No Provider needed
Unlike Context, Zustand stores do not require a Provider wrapper. The store is a module-level singleton. This means less boilerplate and no risk of forgetting to wrap your app. You can also use multiple stores for different domains of your application.
Do not spread the entire store
Avoid `const store = useCartStore()` which subscribes to the entire store object and re-renders on any change. Always select specific slices: `const items = useCartStore((s) => s.items)`. For multiple values, either select them in separate calls or use a shallow-equality selector.
import { create } from 'zustand';
import { persist, devtools, subscribeWithSelector } from 'zustand/middleware';

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

interface CartStore {
  items: CartItem[];
  isOpen: boolean;

  // Actions
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: number) => void;
  updateQuantity: (id: number, quantity: number) => void;
  clearCart: () => void;
  toggleCart: () => void;

  // Derived (computed in selectors)
}

export const useCartStore = create<CartStore>()(
  devtools(
    persist(
      subscribeWithSelector((set) => ({
        items: [],
        isOpen: false,

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

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

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

        clearCart: () => set({ items: [] }),
        toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
      })),
      {
        name: 'cart-storage',
        partialize: (state) => ({ items: state.items }),
      }
    )
  )
);

// Selectors (defined outside store for stable references)
export const selectItemCount = (state: CartStore) =>
  state.items.reduce((sum, item) => sum + item.quantity, 0);

export const selectTotal = (state: CartStore) =>
  state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);