Selectors and Performance
Recipe
Use selector functions with useStore(selector) to subscribe to specific slices of state. Components only re-render when their selected value changes, preventing unnecessary renders.
"use client";
import { useCartStore } from "@/stores/cart-store";
function CartBadge() {
// Only re-renders when items array changes
const itemCount = useCartStore((state) => state.items.length);
return <span className="badge">{itemCount}</span>;
}
function CartTotal() {
// Only re-renders when total changes
const total = useCartStore((state) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
return <span>${total.toFixed(2)}</span>;
}Working Example
// stores/dashboard-store.ts
import { create } from "zustand";
interface DashboardStore {
user: { name: string; avatar: string };
notifications: { id: string; message: string }[];
theme: "light" | "dark";
sidebarOpen: boolean;
toggleSidebar: () => void;
toggleTheme: () => void;
addNotification: (message: string) => void;
}
export const useDashboardStore = create<DashboardStore>((set) => ({
user: { name: "Jane", avatar: "/avatar.png" },
notifications: [],
theme: "light",
sidebarOpen: true,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
toggleTheme: () => set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
addNotification: (message) =>
set((s) => ({
notifications: [...s.notifications, { id: crypto.randomUUID(), message }],
})),
}));// components/header.tsx
"use client";
import { useDashboardStore } from "@/stores/dashboard-store";
import { useShallow } from "zustand/react/shallow";
// Bad: re-renders on ANY store change
function HeaderBad() {
const store = useDashboardStore();
return <div>{store.user.name}</div>;
}
// Good: only re-renders when user.name changes
function HeaderGood() {
const userName = useDashboardStore((s) => s.user.name);
return <div>{userName}</div>;
}
// Good: multiple values with shallow comparison
function HeaderWithMultiple() {
const { name, avatar } = useDashboardStore(
useShallow((s) => ({ name: s.user.name, avatar: s.user.avatar }))
);
return (
<div>
<img src={avatar} alt={name} />
<span>{name}</span>
</div>
);
}
// Good: selecting multiple primitives with useShallow array
function NotificationBar() {
const [notifications, theme] = useDashboardStore(
useShallow((s) => [s.notifications, s.theme])
);
return (
<div className={theme}>
{notifications.length} notifications
</div>
);
}Deep Dive
How It Works
- Zustand uses strict equality (
===) by default to compare the previous and next selected value. - If the selector returns a primitive (string, number, boolean), it only re-renders when the value actually changes.
- If the selector returns a new object or array on every call, the component re-renders on every state change because
{} !== {}. useShallowfromzustand/react/shallowperforms a shallow comparison on the selected object or array, preventing re-renders when individual properties have not changed.- Selectors run synchronously during render. Keep them fast and side-effect-free.
Variations
Auto-generating selectors:
import { create } from "zustand";
import { StoreApi, UseBoundStore } from "zustand";
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never;
function createSelectors<S extends UseBoundStore<StoreApi<object>>>(store: S) {
const storeIn = store as WithSelectors<typeof store>;
storeIn.use = {} as any;
for (const key of Object.keys(storeIn.getState())) {
(storeIn.use as any)[key] = () => storeIn((s: any) => s[key]);
}
return storeIn;
}
// Usage
const useCounterStoreBase = create<CounterState>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
export const useCounterStore = createSelectors(useCounterStoreBase);
// Auto-generated selectors
const count = useCounterStore.use.count();
const increment = useCounterStore.use.increment();Memoized derived selector:
import { useMemo } from "react";
function ExpensiveList() {
const items = useCartStore((s) => s.items);
// Memoize expensive computation
const sortedItems = useMemo(
() => [...items].sort((a, b) => b.price - a.price),
[items]
);
return <ul>{sortedItems.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;
}Selecting actions (stable reference):
// Actions never change, so this selector never triggers a re-render
const increment = useCounterStore((s) => s.increment);
const reset = useCounterStore((s) => s.reset);TypeScript Notes
- Selectors are fully typed. The return type of the selector determines the type of the value.
useShallowpreserves the return type of the selector.
// result is typed as number
const count = useStore((s: StoreState) => s.count);
// result is typed as { name: string; email: string }
const user = useStore(
useShallow((s: StoreState) => ({ name: s.user.name, email: s.user.email }))
);Gotchas
- Returning a new object from a selector without
useShallowcauses re-renders on every state change:(s) => ({ a: s.a, b: s.b })creates a new object each time. useShallowonly compares one level deep. Nested objects or arrays still use reference equality for their children.- Selecting an action function like
(s) => s.incrementnever triggers re-renders because function references are stable in Zustand. This is safe and recommended. - Do not call selectors conditionally. They are hooks internally and must follow React's rules of hooks.
- Avoid expensive computations inside selectors. Use
useMemoafter selecting the raw data instead.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Individual selectors | Precise, minimal re-renders | Verbose for many values |
| useShallow | Multi-value selection, simple API | Only shallow comparison |
| Auto-generated selectors | Zero boilerplate per-field access | Magic, harder to debug |
| No selector (full store) | Simple | Re-renders on every change |
FAQs
What equality check does Zustand use by default for selectors?
- Zustand uses strict equality (
===) to compare the previous and next selected value. - Primitives (string, number, boolean) only trigger re-renders when the value actually changes.
- Objects and arrays trigger re-renders every time because
{} !== {}.
When should you use useShallow and what does it do?
- Use
useShallowwhen your selector returns an object or array with multiple values. - It performs a shallow comparison on the returned object/array, preventing re-renders when individual properties have not changed.
- Import it from
zustand/react/shallow.
How do you select multiple values from a store without causing extra re-renders?
import { useShallow } from "zustand/react/shallow";
const { name, avatar } = useStore(
useShallow((s) => ({ name: s.user.name, avatar: s.user.avatar }))
);Gotcha: Why does (s) => ({ a: s.a, b: s.b }) cause re-renders on every state change?
- This selector creates a new object on every call.
- Since
{} !== {}, Zustand sees it as a new value and triggers a re-render. - Wrap it with
useShallowto compare individual properties instead.
Gotcha: Does useShallow handle deeply nested objects?
- No.
useShallowonly compares one level deep. - Nested objects and arrays still use reference equality for their children.
- For deep comparisons, use a custom equality function or select primitive values.
Does selecting an action function like (s) => s.increment cause re-renders?
- No. Action function references are stable in Zustand -- they never change.
- Selecting actions is safe and recommended.
What are auto-generated selectors and when should you use them?
- A
createSelectorswrapper generates per-field hooks:useStore.use.count(). - This eliminates writing individual selector functions for each field.
- Trade-off: adds "magic" behavior that can be harder to debug.
Should you put expensive computations inside selectors?
- No. Selectors run synchronously during render and should be fast.
- Select the raw data, then use
useMemofor expensive derived computations.
const items = useCartStore((s) => s.items);
const sorted = useMemo(() => [...items].sort(...), [items]);Can you call selectors conditionally in a component?
- No. Zustand hooks follow React's rules of hooks.
- You cannot call a selector inside an
ifblock or after a conditional return.
How are selectors typed in TypeScript?
- The return type of the selector determines the type of the value.
useShallowpreserves the selector's return type.
// result is typed as number
const count = useStore((s: StoreState) => s.count);What is the TypeScript type for the WithSelectors utility used in auto-generated selectors?
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never;- It maps every key in the store state to a hook function.
What is the performance difference between individual selectors and useShallow?
- Individual selectors are the most precise: one subscription per value.
useShallowselects multiple values but adds a shallow comparison pass.- Both are far better than selecting the entire store with no selector.