React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

zustandselectorscontextuseReducerderived-stateurl-statere-rendersperformance

State Management Performance — Zustand selectors, context splitting, and derived state

Recipe

// Zustand with selectors — components only re-render when their slice changes
import { create } from "zustand";
 
interface AppStore {
  user: { name: string; role: string } | null;
  cart: CartItem[];
  notifications: Notification[];
  addToCart: (item: CartItem) => void;
  clearNotifications: () => void;
}
 
const useAppStore = create<AppStore>((set) => ({
  user: null,
  cart: [],
  notifications: [],
  addToCart: (item) => set((s) => ({ cart: [...s.cart, item] })),
  clearNotifications: () => set({ notifications: [] }),
}));
 
// GOOD: Selector — only re-renders when cart changes
function CartBadge() {
  const cartCount = useAppStore((s) => s.cart.length);
  return <span>{cartCount}</span>;
}
 
// BAD: No selector — re-renders on ANY store change
function CartBadgeBad() {
  const store = useAppStore();  // Subscribes to entire store!
  return <span>{store.cart.length}</span>;
}

When to reach for this: When React DevTools shows components re-rendering due to context or store changes that are unrelated to what they display. Common in dashboards and apps with shared global state.

Working Example

// ---- BEFORE: Full-store subscription — 47 unnecessary re-renders per interaction ----
 
import { create } from "zustand";
 
interface DashboardStore {
  user: { name: string; avatar: string; role: string };
  theme: "light" | "dark";
  sidebarOpen: boolean;
  notifications: Notification[];
  activeTab: string;
  searchQuery: string;
  filters: Record<string, string>;
  cart: CartItem[];
  setSidebarOpen: (open: boolean) => void;
  setActiveTab: (tab: string) => void;
  setSearchQuery: (query: string) => void;
  addNotification: (n: Notification) => void;
  addToCart: (item: CartItem) => void;
}
 
const useDashboardStore = create<DashboardStore>((set) => ({
  user: { name: "Alice", avatar: "/alice.jpg", role: "admin" },
  theme: "light",
  sidebarOpen: true,
  notifications: [],
  activeTab: "overview",
  searchQuery: "",
  filters: {},
  cart: [],
  setSidebarOpen: (open) => set({ sidebarOpen: open }),
  setActiveTab: (tab) => set({ activeTab: tab }),
  setSearchQuery: (query) => set({ searchQuery: query }),
  addNotification: (n) => set((s) => ({ notifications: [...s.notifications, n] })),
  addToCart: (item) => set((s) => ({ cart: [...s.cart, item] })),
}));
 
// Every component subscribes to the entire store
function Header() {
  const store = useDashboardStore(); // Re-renders on sidebar toggle, search, tab change, etc.
  return <header>{store.user.name} ({store.notifications.length})</header>;
}
 
function Sidebar() {
  const store = useDashboardStore(); // Re-renders on search, notifications, cart, etc.
  return <nav className={store.sidebarOpen ? "w-64" : "w-16"}>{/* ... */}</nav>;
}
 
function SearchBar() {
  const store = useDashboardStore(); // Re-renders on sidebar, notifications, cart, etc.
  return (
    <input
      value={store.searchQuery}
      onChange={(e) => store.setSearchQuery(e.target.value)}
    />
  );
}
 
function TabPanel() {
  const store = useDashboardStore(); // Re-renders on everything
  return <div>Active: {store.activeTab}</div>;
}
 
function CartIcon() {
  const store = useDashboardStore(); // Re-renders on everything
  return <span>Cart: {store.cart.length}</span>;
}
 
// Typing in SearchBar re-renders ALL 5 components = 47 re-renders for 10 keystrokes
 
// ---- AFTER: Selectors — each component re-renders only for its own data ----
 
// Same store definition, but with selectors
 
function Header() {
  const userName = useDashboardStore((s) => s.user.name);
  const notificationCount = useDashboardStore((s) => s.notifications.length);
  // Only re-renders when user.name or notifications.length changes
  return <header>{userName} ({notificationCount})</header>;
}
 
function Sidebar() {
  const sidebarOpen = useDashboardStore((s) => s.sidebarOpen);
  const setSidebarOpen = useDashboardStore((s) => s.setSidebarOpen);
  // Only re-renders when sidebarOpen changes
  return (
    <nav className={sidebarOpen ? "w-64" : "w-16"}>
      <button onClick={() => setSidebarOpen(!sidebarOpen)}>Toggle</button>
    </nav>
  );
}
 
function SearchBar() {
  const searchQuery = useDashboardStore((s) => s.searchQuery);
  const setSearchQuery = useDashboardStore((s) => s.setSearchQuery);
  // Only re-renders when searchQuery changes
  return (
    <input
      value={searchQuery}
      onChange={(e) => setSearchQuery(e.target.value)}
    />
  );
}
 
function TabPanel() {
  const activeTab = useDashboardStore((s) => s.activeTab);
  // Only re-renders when activeTab changes
  return <div>Active: {activeTab}</div>;
}
 
function CartIcon() {
  const cartCount = useDashboardStore((s) => s.cart.length);
  // Only re-renders when cart.length changes
  return <span>Cart: {cartCount}</span>;
}
 
// Typing in SearchBar re-renders ONLY SearchBar = 10 re-renders for 10 keystrokes
// Reduction: 47 re-renders to 10 (79% fewer)

What this demonstrates:

  • Without selectors: typing in SearchBar triggers 5 components x 10 keystrokes = 47 re-renders
  • With selectors: only SearchBar re-renders = 10 re-renders (79% reduction)
  • Each component subscribes to exactly the data it needs
  • Actions (setSidebarOpen, setSearchQuery) are stable references — no need for useCallback

Deep Dive

How It Works

  • Zustand selectors use Object.is to compare the selected value between store updates. If the selector returns the same value, the component does not re-render. This is fundamentally different from React Context, which re-renders all consumers on any change.
  • Context splitting separates state from dispatch into different contexts. Components that only call actions (dispatch) do not re-render when state changes, because the dispatch function reference is stable.
  • Derived state means computing values from existing state instead of storing them separately. Storing filteredItems alongside items and filter creates a synchronization problem and doubles the state update surface.
  • URL state with useSearchParams keeps filter and pagination state in the URL. This is inherently non-reactive (no re-renders until navigation), shareable via URL, and persists across page refreshes.
  • useReducer consolidates related state transitions into a single dispatch function. This prevents impossible state combinations and reduces the number of state setters passed as props.

Variations

Context splitting pattern:

// BEFORE: Single context — all consumers re-render on any change
const AppContext = createContext<{
  state: AppState;
  dispatch: Dispatch<Action>;
} | null>(null);
 
// AFTER: Split contexts — dispatch consumers never re-render on state changes
const StateContext = createContext<AppState | null>(null);
const DispatchContext = createContext<Dispatch<Action> | null>(null);
 
function AppProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, initialState);
 
  return (
    <DispatchContext.Provider value={dispatch}>
      <StateContext.Provider value={state}>{children}</StateContext.Provider>
    </DispatchContext.Provider>
  );
}
 
// This component only dispatches — never re-renders on state changes
function AddButton() {
  const dispatch = useContext(DispatchContext)!;
  return <button onClick={() => dispatch({ type: "ADD" })}>Add</button>;
}
 
// This component only reads count — re-renders only when count changes
// (still re-renders on ANY state change — use useMemo for further optimization)
function Counter() {
  const state = useContext(StateContext)!;
  return <span>{state.count}</span>;
}

Derived state instead of stored state:

// BAD: Storing derived state — must keep filteredProducts in sync
const [products, setProducts] = useState<Product[]>([]);
const [filter, setFilter] = useState("all");
const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);
 
useEffect(() => {
  setFilteredProducts(products.filter((p) => filter === "all" || p.category === filter));
}, [products, filter]);
 
// GOOD: Compute on render — always in sync, no effect needed
const [products, setProducts] = useState<Product[]>([]);
const [filter, setFilter] = useState("all");
 
const filteredProducts = useMemo(
  () => products.filter((p) => filter === "all" || p.category === filter),
  [products, filter]
);

URL state for filters and pagination:

"use client";
 
import { useSearchParams, useRouter, usePathname } from "next/navigation";
 
function ProductFilters() {
  const searchParams = useSearchParams();
  const router = useRouter();
  const pathname = usePathname();
 
  const category = searchParams.get("category") || "all";
  const sort = searchParams.get("sort") || "name";
  const page = parseInt(searchParams.get("page") || "1");
 
  function updateParam(key: string, value: string) {
    const params = new URLSearchParams(searchParams);
    params.set(key, value);
    params.set("page", "1"); // Reset page on filter change
    router.push(`${pathname}?${params.toString()}`);
  }
 
  return (
    <div className="flex gap-4">
      <select value={category} onChange={(e) => updateParam("category", e.target.value)}>
        <option value="all">All</option>
        <option value="electronics">Electronics</option>
      </select>
      <select value={sort} onChange={(e) => updateParam("sort", e.target.value)}>
        <option value="name">Name</option>
        <option value="price">Price</option>
      </select>
    </div>
  );
}

TypeScript Notes

  • Zustand selectors are fully typed: useStore((s) => s.field) infers the return type from the store interface.
  • For complex selectors, use useShallow from zustand/react/shallow to compare object selections: useStore(useShallow((s) => ({ a: s.a, b: s.b }))).
  • useReducer infers action types from the reducer function. Use a discriminated union for the action type.

Gotchas

  • Selecting objects without useShallowuseStore((s) => ({ a: s.a, b: s.b })) creates a new object on every store update, defeating the selector optimization. Fix: Use useShallow from zustand/react/shallow to compare object properties shallowly.

  • Inline selectors creating new referencesuseStore((s) => s.items.filter(...)) creates a new array on every store update, even if the filter result is identical. Fix: Either memoize the selector outside the component or use useMemo inside the component.

  • Context with too many values — A single context providing { user, theme, locale, cart, notifications } re-renders all consumers when any value changes. Fix: Split into separate contexts per domain, or switch to Zustand with selectors.

  • Storing derived state — Keeping filteredItems in state alongside items and filter requires manual synchronization and causes double-render bugs. Fix: Compute derived state with useMemo on every render. It is always in sync and often cheaper than the effect-based alternative.

  • Using useState for complex state — Multiple related useState calls (loading, error, data) can get out of sync. Fix: Use useReducer for state that has multiple sub-values or complex transitions.

  • Prop drilling vs context performance — Prop drilling does not cause extra re-renders (only the target component re-renders). Context causes all consumers to re-render. Fix: If only 2-3 levels deep, prop drilling is more performant than context.

Alternatives

ApproachTrade-off
Zustand with selectorsFine-grained subscriptions; external dependency
Split React ContextNo dependency; verbose setup, still re-renders all state consumers
JotaiAtomic state model; different mental model from Zustand
ValtioProxy-based; automatic tracking; less explicit
URL stateShareable, persistent; only for serializable filter/pagination state
React CompilerAuto-optimizes context consumers; experimental
Prop drillingZero overhead; impractical for deeply nested components

FAQs

Why do Zustand selectors prevent unnecessary re-renders?
  • Selectors use Object.is to compare the selected value between store updates.
  • If the selector returns the same value, the component skips re-rendering.
  • Without a selector, the component subscribes to the entire store and re-renders on any change.
What is the problem with useStore() without a selector argument?
// BAD: re-renders on ANY store change (sidebar, search, cart, etc.)
const store = useAppStore();
return <span>{store.cart.length}</span>;
 
// GOOD: re-renders only when cart.length changes
const cartCount = useAppStore((s) => s.cart.length);
return <span>{cartCount}</span>;
How does context splitting prevent re-renders on dispatch-only components?
  • Split state and dispatch into separate contexts.
  • Components that only call actions use the dispatch context, which has a stable reference.
  • They never re-render when state changes because they are not subscribed to the state context.
Why is computing derived state with useMemo better than storing it in state?
  • Stored derived state (filteredProducts alongside products and filter) requires manual sync via useEffect.
  • This causes double-render bugs and extra state update surface.
  • useMemo computes on render -- always in sync, no effect needed.
When should you use URL state instead of component state for filters?
  • When filter/pagination state should be shareable via URL.
  • When state should persist across page refreshes.
  • URL state is inherently non-reactive (no re-renders until navigation), reducing unnecessary updates.
Gotcha: Why does selecting an object from Zustand without useShallow defeat the optimization?
  • useStore((s) => ({ a: s.a, b: s.b })) creates a new object reference on every store update.
  • Object.is sees a different reference each time and triggers a re-render.
  • Fix: Use useShallow from zustand/react/shallow for object selections.
import { useShallow } from "zustand/react/shallow";
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));
Why is prop drilling sometimes more performant than React Context?
  • Prop drilling only re-renders the target component that receives the changed prop.
  • Context re-renders ALL consumers when any value in the context changes.
  • If only 2-3 levels deep, prop drilling avoids the broadcast re-render problem.
Gotcha: Why does using multiple useState calls for related state cause bugs?
  • Multiple related useState calls (loading, error, data) can get out of sync during updates.
  • Setting error and forgetting to clear loading produces impossible states.
  • Fix: Use useReducer for state with multiple sub-values or complex transitions.
How are Zustand selectors typed in TypeScript?
  • useStore((s) => s.field) infers the return type from the store interface automatically.
  • For multi-field selections, use useShallow which preserves the inferred object type.
interface AppStore { cart: CartItem[]; user: User | null; }
 
// cartCount is inferred as number
const cartCount = useAppStore((s) => s.cart.length);
How do you type a useReducer action as a discriminated union in TypeScript?
type Action =
  | { type: "SET_LOADING"; payload: boolean }
  | { type: "SET_DATA"; payload: Product[] }
  | { type: "SET_ERROR"; payload: string };
 
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "SET_LOADING": return { ...state, loading: action.payload };
    case "SET_DATA": return { ...state, data: action.payload, loading: false };
    case "SET_ERROR": return { ...state, error: action.payload, loading: false };
  }
}
How does useSearchParams work for URL-based state in Next.js?
"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
 
function Filters() {
  const searchParams = useSearchParams();
  const router = useRouter();
  const pathname = usePathname();
  const category = searchParams.get("category") || "all";
 
  function updateFilter(key: string, value: string) {
    const params = new URLSearchParams(searchParams);
    params.set(key, value);
    router.push(`${pathname}?${params.toString()}`);
  }
}
When should you choose Zustand over React Context for state management?
  • When multiple unrelated components subscribe to different slices of the same state.
  • When you need fine-grained subscriptions without re-rendering all consumers.
  • Context is fine for simple, rarely-changing state (theme, locale); Zustand is better for frequently-updated state (search, filters, cart).