Zustand External Store State Management

Frontend Engineering intermediate Building with AI ~30 min read

Overview

Zustand is a small React state-management library built around an external store, selector-based subscriptions, and action functions. Instead of putting shared application state into React Context and re-rendering every consumer whenever a provider value changes, Zustand keeps state outside React and lets each component subscribe to the slice it actually reads.

The transferable pattern is not the library brand; it is the external-store architecture. A store exposes getState, setState, and subscribe. React components bind to that store through hooks, usually with selectors that return one field, one derived value, or one action. This gives teams global client state without prop drilling, while keeping re-render boundaries narrow enough for high-interaction interfaces.

This topic covers how Zustand-style stores work, why selectors and equality functions matter, how middleware extends the store, and where external stores fit beside Context, reducers, URL state, and server-state caches such as TanStack Query.

Core Concepts

External Store

An external store is mutable state that lives outside React’s component tree. React does not own the data directly; it subscribes to snapshots of that data. This is useful for state shared across distant components, state updated by non-React code, or hot paths where Context would broadcast too broadly.

Selectors

A selector is a function that maps the full store state to the value a component needs. useStore((state) => state.user.name) subscribes to the user’s name, not to every field in the store. When the store changes, the selector runs again, and React re-renders the component only if the selected result changed by the configured equality rule.

Actions in the Store

Zustand stores commonly colocate actions with data. The action receives set and optionally get, then updates state immutably. This keeps write paths discoverable and testable without requiring reducers, action-type constants, or a provider wrapper at the app root.

Key Concept: Granular Subscription

The main performance win comes from subscribing to specific values, not from making global state magic. A component that selects one boolean should not re-render because a different list or form field changed elsewhere in the store.

How It Works

Creating a Store Hook

The common API creates a hook from a store initializer. The initializer returns the initial state and action functions. set accepts either a partial object or a function of the previous state; by default Zustand merges the partial result into the current state.

import { create } from 'zustand';

type SessionState = {
  userId: string | null;
  sidebarOpen: boolean;
  setUserId: (id: string | null) => void;
  toggleSidebar: () => void;
};

export const useSessionStore = create<SessionState>((set) => ({
  userId: null,
  sidebarOpen: false,
  setUserId: (id) => set({ userId: id }),
  toggleSidebar: () =>
    set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}));

function SidebarButton() {
  const open = useSessionStore((state) => state.sidebarOpen);
  const toggle = useSessionStore((state) => state.toggleSidebar);

  return (
    <button aria-pressed={open} onClick={toggle}>
      Toggle sidebar
    </button>
  );
}

Selectors and Equality

Strict reference equality is efficient when a selector returns a primitive or a stable object reference. If a selector constructs a new object or array on every call, it can force re-renders even when the underlying values did not change. For grouped selections, use shallow comparison or split the selection into multiple atomic calls.

import { useShallow } from 'zustand/react/shallow';

function Header() {
  const { userId, sidebarOpen } = useSessionStore(
    useShallow((state) => ({
      userId: state.userId,
      sidebarOpen: state.sidebarOpen,
    })),
  );

  return <span>{userId ?? 'signed out'} / {String(sidebarOpen)}</span>;
}

Subscribing Outside React

The store API can be used outside components. This is useful for logging, persistence, browser events, WebSocket handlers, or imperative rendering code. When subscribing outside React, always retain and call the unsubscribe function during cleanup.

Practical Applications

Client-Only UI State

External stores work well for modal state, sidebars, selected rows, command-palette state, in-progress drafts, optimistic UI flags, and other client-only values that many components need. These values do not naturally belong in the URL, and they are not server cache entries.

Interactive Canvas and Visualization State

High-frequency interactions such as drag handles, cursor positions, and graph selections can update too often for Context. A store subscription can update only the relevant control panel, while an imperative subscriber updates a canvas or WebGL scene without forcing a full React subtree to render on every event.

Cross-Cutting Application Controls

Theme selectors, feature flags loaded at startup, authenticated session summaries, and layout preferences often need to be read from distant parts of the tree. A small external store avoids deeply nested providers and keeps these controls decoupled from individual page components.

Implementation Patterns

Keep Server State Out of the Client Store

Use Zustand for synchronous client state, not as a replacement for a server-state cache. Data fetched from APIs usually needs stale-time rules, request deduplication, retries, cancellation, invalidation, and background refetching. Keep that in TanStack Query or an equivalent cache, then store only local UI choices in Zustand.

type SearchUiState = {
  selectedFacets: string[];
  sort: 'relevance' | 'newest';
  setSort: (sort: SearchUiState['sort']) => void;
  toggleFacet: (facet: string) => void;
};

export const useSearchUiStore = create<SearchUiState>((set) => ({
  selectedFacets: [],
  sort: 'relevance',
  setSort: (sort) => set({ sort }),
  toggleFacet: (facet) =>
    set((state) => ({
      selectedFacets: state.selectedFacets.includes(facet)
        ? state.selectedFacets.filter((item) => item !== facet)
        : [...state.selectedFacets, facet],
    })),
}));

Use Middleware for Cross-Cutting Behavior

Zustand middleware can add persistence, Redux DevTools integration, Immer updates, or selector-aware subscriptions. Treat middleware as store infrastructure: configure it once near store creation, document what it changes, and avoid hiding critical business rules inside generic wrappers.

Use Vanilla Stores for Dependency Injection

When a store needs per-instance initialization, create a vanilla store and pass it through a small Context provider. This keeps the external-store performance model while letting a page, tenant, or embedded widget own its own store instance.

Performance Considerations

Common Pitfalls

Further Reading