Zustand for Client UI State
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.
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>;
}
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);
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);
}),
}))
);
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.
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?
| Feature | React Context | Zustand |
|---|---|---|
| Re-render behavior | All consumers re-render on any change | Only consumers whose selected value changed |
| Provider required | Yes — must wrap component tree | No — works anywhere |
| Selector support | None — all-or-nothing subscriptions | Built-in — subscribe to specific slices |
| Middleware | None | persist, devtools, immer, and custom |
| Bundle size | 0 kB (built into React) | ~1.1 kB gzipped |
| Best for | Low-frequency: theme, locale, auth user | Any frequency: UI state, live data, animations |
| DevTools | React DevTools only | Redux DevTools with time-travel |
| Outside React | No — requires React tree | Yes — works in plain JS |
- 1Use Context for low-frequency, app-wide state: theme, locale, auth. It's free (0 kB) and built-in.
- 2Use Zustand when you need selector-based subscriptions to prevent unnecessary re-renders.
- 3Always use individual primitive selectors or useShallow — never return new objects from selectors without shallow comparison.
- 4The slices pattern keeps large stores organized. Each slice is independently testable.
- 5persist middleware needs version and migrate for schema evolution. Don't skip this.
- 6Transient subscriptions bypass React entirely — use for 60fps updates like cursor tracking.
| What developers do | What 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. |