React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

zustandselectorsshallowperformance

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 {} !== {}.
  • useShallow from zustand/react/shallow performs 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.
  • useShallow preserves 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 useShallow causes re-renders on every state change: (s) => ({ a: s.a, b: s.b }) creates a new object each time.
  • useShallow only compares one level deep. Nested objects or arrays still use reference equality for their children.
  • Selecting an action function like (s) => s.increment never 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 useMemo after selecting the raw data instead.

Alternatives

ApproachProsCons
Individual selectorsPrecise, minimal re-rendersVerbose for many values
useShallowMulti-value selection, simple APIOnly shallow comparison
Auto-generated selectorsZero boilerplate per-field accessMagic, harder to debug
No selector (full store)SimpleRe-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 useShallow when 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 useShallow to compare individual properties instead.
Gotcha: Does useShallow handle deeply nested objects?
  • No. useShallow only 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 createSelectors wrapper 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 useMemo for 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 if block or after a conditional return.
How are selectors typed in TypeScript?
  • The return type of the selector determines the type of the value.
  • useShallow preserves 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.
  • useShallow selects multiple values but adds a shallow comparison pass.
  • Both are far better than selecting the entire store with no selector.