Skip to content

Zustand for Client UI State

advanced20 min read

Why Zustand Exists

React Context has a problem. Not a small problem — a fundamental architectural limitation: it has no selector mechanism. When a Context value changes, every component that calls useContext(MyContext) re-renders, even if it only cares about one property that didn't change.

For a theme toggle that changes once per session, this is fine. For a UI store that updates 30 times per second during a drag operation, it's a performance disaster.

Zustand solves this with a dead-simple premise: a store with selector-based subscriptions. Components subscribe to exactly the data they need. Nothing more, nothing less. If a component reads sidebarOpen and you update activeModal, that component doesn't re-render.

Mental Model

Zustand is a pub-sub store that lives outside of React's tree. There's no Provider wrapping your app. Components subscribe to slices of the store using selectors, like watching specific channels on a TV instead of receiving every broadcast. The store is just a plain JavaScript object with functions to modify it — no reducers, no actions, no dispatch. Call a function, the state updates, and only the components watching the changed values re-render.

The Minimal API

Zustand's entire API surface for basic usage is one function: create.

import { create } from 'zustand';

interface UIStore {
  sidebarOpen: boolean;
  activeModal: string | null;
  toggleSidebar: () => void;
  openModal: (id: string) => void;
  closeModal: () => void;
}

const useUIStore = create<UIStore>((set) => ({
  sidebarOpen: false,
  activeModal: null,
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
  openModal: (id) => set({ activeModal: id }),
  closeModal: () => set({ activeModal: null }),
}));

That's the entire store. No Provider, no boilerplate, no ceremony. Use it in any component:

function Sidebar() {
  const isOpen = useUIStore((s) => s.sidebarOpen);
  const toggle = useUIStore((s) => s.toggleSidebar);
  return <nav className={isOpen ? 'open' : 'closed'}>{/* ... */}</nav>;
}
Quiz
In the Zustand store above, if openModal('settings') is called, which components re-render?

No Provider Required

This is one of Zustand's killer features. Unlike Context or Redux, Zustand stores exist outside React's component tree. No wrapping your app in providers:

// This just works. No Provider wrapper needed.
function DeepNestedComponent() {
  const sidebarOpen = useUIStore((s) => s.sidebarOpen);
  return <div>{sidebarOpen ? 'Open' : 'Closed'}</div>;
}

This also means Zustand stores work in non-React code — server-side utilities, test helpers, or even vanilla JavaScript modules that need to read app state.

Selectors: The Performance Key

The selector pattern is what makes Zustand fast. But there's a subtle trap: returning a new object from a selector creates a new reference every time, defeating the optimization.

// BAD — creates a new object on every store update, always re-renders
const { sidebarOpen, activeModal } = useUIStore((s) => ({
  sidebarOpen: s.sidebarOpen,
  activeModal: s.activeModal,
}));

// GOOD — use shallow comparison for object selectors
import { useShallow } from 'zustand/react/shallow';

const { sidebarOpen, activeModal } = useUIStore(
  useShallow((s) => ({
    sidebarOpen: s.sidebarOpen,
    activeModal: s.activeModal,
  }))
);

// BEST — separate selectors for unrelated values
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const activeModal = useUIStore((s) => s.activeModal);
Quiz
Why does useUIStore(s => ({ a: s.a, b: s.b })) cause unnecessary re-renders?

The Slices Pattern

As stores grow, keeping everything in one file becomes unwieldy. The slices pattern lets you split your store into logical domains:

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

interface SidebarSlice {
  sidebarOpen: boolean;
  toggleSidebar: () => void;
}

interface ModalSlice {
  activeModal: string | null;
  openModal: (id: string) => void;
  closeModal: () => void;
}

interface NotificationSlice {
  unreadCount: number;
  incrementUnread: () => void;
  resetUnread: () => void;
}

const createSidebarSlice: StateCreator<
  SidebarSlice & ModalSlice & NotificationSlice,
  [],
  [],
  SidebarSlice
> = (set) => ({
  sidebarOpen: false,
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
});

const createModalSlice: StateCreator<
  SidebarSlice & ModalSlice & NotificationSlice,
  [],
  [],
  ModalSlice
> = (set) => ({
  activeModal: null,
  openModal: (id) => set({ activeModal: id }),
  closeModal: () => set({ activeModal: null }),
});

const createNotificationSlice: StateCreator<
  SidebarSlice & ModalSlice & NotificationSlice,
  [],
  [],
  NotificationSlice
> = (set) => ({
  unreadCount: 0,
  incrementUnread: () => set((s) => ({ unreadCount: s.unreadCount + 1 })),
  resetUnread: () => set({ unreadCount: 0 }),
});

const useAppStore = create<SidebarSlice & ModalSlice & NotificationSlice>()(
  (...args) => ({
    ...createSidebarSlice(...args),
    ...createModalSlice(...args),
    ...createNotificationSlice(...args),
  })
);

Each slice is a self-contained unit that can be tested independently. The combined store merges them all into one flat state object.

Middleware

Zustand's middleware system wraps the store creator to add functionality. The three most useful built-in middlewares:

persist — Survive Page Refreshes

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

const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: 'system' as 'light' | 'dark' | 'system',
      fontSize: 16,
      setTheme: (theme) => set({ theme }),
      setFontSize: (size) => set({ fontSize: size }),
    }),
    {
      name: 'user-settings',
      partialize: (state) => ({ theme: state.theme, fontSize: state.fontSize }),
    }
  )
);

The partialize option is important — it controls which parts of the state get persisted. You don't want to persist functions or transient UI state.

devtools — Time-Travel Debugging

import { devtools } from 'zustand/middleware';

const useStore = create<MyStore>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set((s) => ({ count: s.count + 1 }), undefined, 'increment'),
    }),
    { name: 'MyStore' }
  )
);

The third argument to set names the action in Redux DevTools. Yes, Zustand works with Redux DevTools — you get time-travel debugging without Redux.

immer — Mutable-Looking Immutable Updates

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

const useStore = create<MyStore>()(
  immer((set) => ({
    users: [] as User[],
    addUser: (user: User) =>
      set((state) => {
        state.users.push(user);
      }),
    updateUser: (id: string, updates: Partial<User>) =>
      set((state) => {
        const user = state.users.find((u) => u.id === id);
        if (user) Object.assign(user, updates);
      }),
  }))
);
Common Trap

Middleware order matters. The outermost middleware wraps the innermost. If you want devtools to track immer updates, devtools goes outside:

const useStore = create<MyStore>()(
  devtools(
    persist(
      immer((set) => ({ /* ... */ })),
      { name: 'store' }
    ),
    { name: 'MyStore' }
  )
);

If you reverse the order, devtools won't see the persisted rehydration events.

Quiz
You're using Zustand's persist middleware, but after adding a new property to your store, existing users see undefined for the new field. Why?

Transient Updates

For high-frequency updates where you don't want React re-renders at all (mouse position tracking, animation frames), Zustand supports transient subscriptions:

const useMouseStore = create<MouseStore>((set, get) => ({
  x: 0,
  y: 0,
  setPosition: (x: number, y: number) => set({ x, y }),
}));

function Cursor() {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const unsub = useMouseStore.subscribe((state) => {
      if (ref.current) {
        ref.current.style.transform = `translate(${state.x}px, ${state.y}px)`;
      }
    });
    return unsub;
  }, []);

  return <div ref={ref} className="cursor" />;
}

This bypasses React's render cycle entirely. The store updates, the subscription fires, and you update the DOM directly. No virtual DOM diffing, no reconciliation. Pure performance for 60fps updates.

Zustand vs Context API

When should you use which?

FeatureReact ContextZustand
Re-render behaviorAll consumers re-render on any changeOnly consumers whose selected value changed
Provider requiredYes — must wrap component treeNo — works anywhere
Selector supportNone — all-or-nothing subscriptionsBuilt-in — subscribe to specific slices
MiddlewareNonepersist, devtools, immer, and custom
Bundle size0 kB (built into React)~1.1 kB gzipped
Best forLow-frequency: theme, locale, auth userAny frequency: UI state, live data, animations
DevToolsReact DevTools onlyRedux DevTools with time-travel
Outside ReactNo — requires React treeYes — works in plain JS
Key Rules
  1. 1Use Context for low-frequency, app-wide state: theme, locale, auth. It's free (0 kB) and built-in.
  2. 2Use Zustand when you need selector-based subscriptions to prevent unnecessary re-renders.
  3. 3Always use individual primitive selectors or useShallow — never return new objects from selectors without shallow comparison.
  4. 4The slices pattern keeps large stores organized. Each slice is independently testable.
  5. 5persist middleware needs version and migrate for schema evolution. Don't skip this.
  6. 6Transient subscriptions bypass React entirely — use for 60fps updates like cursor tracking.
What developers doWhat they should do
Putting server-fetched data in Zustand and managing loading/error/caching manually
Zustand has no concept of staleness, deduplication, background refetching, or cache invalidation. You'd rebuild TanStack Query badly.
Use TanStack Query for server state. Zustand is for client-only state that the server doesn't know about.
Creating one massive Zustand store for the entire application
Smaller stores are easier to test, easier to reason about, and selectors are simpler. One mega-store couples unrelated concerns.
Create multiple focused stores: useUIStore, useSettingsStore, useNotificationStore
Storing derived state in the store (e.g., filteredItems alongside items and filter)
Derived state creates synchronization bugs — you must remember to update filteredItems every time items or filter changes. Compute on read, not on write.
Compute derived values in selectors or useMemo. Store only the source values.