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 foruseCallback
Deep Dive
How It Works
- Zustand selectors use
Object.isto 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
filteredItemsalongsideitemsandfiltercreates a synchronization problem and doubles the state update surface. - URL state with
useSearchParamskeeps 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. useReducerconsolidates 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
useShallowfromzustand/react/shallowto compare object selections:useStore(useShallow((s) => ({ a: s.a, b: s.b }))). useReducerinfers action types from the reducer function. Use a discriminated union for the action type.
Gotchas
-
Selecting objects without
useShallow—useStore((s) => ({ a: s.a, b: s.b }))creates a new object on every store update, defeating the selector optimization. Fix: UseuseShallowfromzustand/react/shallowto compare object properties shallowly. -
Inline selectors creating new references —
useStore((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 useuseMemoinside 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
filteredItemsin state alongsideitemsandfilterrequires manual synchronization and causes double-render bugs. Fix: Compute derived state withuseMemoon every render. It is always in sync and often cheaper than the effect-based alternative. -
Using useState for complex state — Multiple related
useStatecalls (loading,error,data) can get out of sync. Fix: UseuseReducerfor 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
| Approach | Trade-off |
|---|---|
| Zustand with selectors | Fine-grained subscriptions; external dependency |
| Split React Context | No dependency; verbose setup, still re-renders all state consumers |
| Jotai | Atomic state model; different mental model from Zustand |
| Valtio | Proxy-based; automatic tracking; less explicit |
| URL state | Shareable, persistent; only for serializable filter/pagination state |
| React Compiler | Auto-optimizes context consumers; experimental |
| Prop drilling | Zero overhead; impractical for deeply nested components |
FAQs
Why do Zustand selectors prevent unnecessary re-renders?
- Selectors use
Object.isto 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 (
filteredProductsalongsideproductsandfilter) requires manual sync viauseEffect. - This causes double-render bugs and extra state update surface.
useMemocomputes 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.issees a different reference each time and triggers a re-render.- Fix: Use
useShallowfromzustand/react/shallowfor 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
useStatecalls (loading,error,data) can get out of sync during updates. - Setting
errorand forgetting to clearloadingproduces impossible states. - Fix: Use
useReducerfor 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
useShallowwhich 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).
Related
- Preventing Re-renders — Structural patterns to reduce re-renders
- Memoization — useMemo for derived state computation
- Data Fetching Performance — Server state vs client state
- Performance Checklist — State management audit items