Diagnosing Zustand Selector Re-render Issues: Reference Equality Pitfalls and Fixes
A diagnostic guide for Zustand selector reference-equality pitfalls: why components over-render or miss updates, with cause table, ordered checks, fixes (shallow, deep, Immer, subscribeWithSelector), TypeScript guards, and escalation steps.
08 Jul 2025, 01:39 UTC

Recognizable Condition
Your React component using Zustand either re-renders on every parent render despite unchanged data, or fails to re-render when nested state actually changes. Both symptoms stem from how Zustand compares selector return values by strict reference equality (===) by default.
Cause and Diagnostic Table
| Symptom | Typical Cause | Quick Test |
|---|---|---|
| Component re-renders on every store update or parent render | Selector returns a new object/array literal each call (e.g., state => ({ a: state.a, b: state.b })) |
Wrap selector with shallow equality; if re-renders stop, cause confirmed |
| Component does not re-render when nested data changes | State mutated in-place (state.arr.push(x)) instead of replaced immutably |
Add Immer middleware or spread operator; if re-render appears, cause confirmed |
| Re-renders persist after shallow equality | Nested objects/arrays replaced with new references despite same content | Try deep equality (lodash.isEqual); profile performance impact |
Ordered Checks
- Inspect selector shape. Open the component and locate the
useStorecall. Does the selector return an object or array literal? Example:useStore(state => ({ items: state.items, filter: state.filter }))creates a new object every invocation. - Verify state updates are immutable. Check the store's
setcalls. Are arrays/objects replaced (set({ items: [...state.items, newItem] })) or mutated (state.items.push(newItem))? Mutation bypasses reference change detection. - Measure re-render frequency. Use React DevTools Profiler. Record a session, trigger the suspect update, and note render count for the component. Baseline: one render per actual data change.
- Apply shallow equality and re-profile. Import
shallowfromzustand/shallowand pass as second argument:useStore(selector, shallow). If render count drops to baseline, the issue was first-level reference churn. - If shallow fails, test deep equality. Install
lodash.isEqual(orfast-deep-equal) and pass a custom function:useStore(selector, (a, b) => isEqual(a, b)). Re-profile. Warning: deep equality is O(n) on the selected slice; avoid on large trees.
Fixes Tied to Findings
Over-rendering from New Object Literals
Problem: Selector returns { items: state.items } each call. Even when state.items reference is stable, the wrapper object is new.
// Before: triggers re-render on every store update
const { items } = useStore(state => ({ items: state.items }));
// After: shallow compares first-level keys only
import { shallow } from 'zustand/shallow';
const { items } = useStore(state => ({ items: state.items }), shallow);
Use shallow when the selector returns a flat object or array of primitives. It compares each top-level property by reference, which is O(k) where k is key count — negligible for typical slices.
Missed Re-renders from In-place Mutation
Problem: Code mutates state.arr.push(x) inside a setter. Zustand sees the same array reference, so selectors return identical references and React skips re-render.
// Mutating (broken)
set(state => { state.arr.push(newItem); });
// Immutable replacement (fixed)
set(state => ({ arr: [...state.arr, newItem] }));
// Or use Immer middleware for mutable syntax with immutable results
import { immer } from 'zustand/middleware/immer';
const useStore = create(immer(set => ({
arr: [],
add: item => set(state => { state.arr.push(item); })
})));
Immer lets you write mutating code while producing new references. Add it once at store creation; all setters gain immutable semantics.
Deep Comparison for Nested Structures
When shallow isn't enough — e.g., selector returns { user: { profile: { settings: ... } } } and a nested field changes — use a deep equality function.
import isEqual from 'lodash/isEqual';
const data = useStore(selector, (a, b) => isEqual(a, b));
Performance guard: Profile before committing. On a 5,000-node tree, isEqual can add 2–5 ms per store update per subscribed component. If the slice exceeds ~500 nodes, consider restructuring state to keep selectors flat.
Fine-grained Subscriptions Outside Render Cycle
For side effects (localStorage sync, WebSocket messages, canvas draws), avoid hook subscriptions entirely. Use the subscribeWithSelector middleware:
import { subscribeWithSelector } from 'zustand/middleware';
const useStore = create(subscribeWithSelector(set => ({ count: 0 })));
// In a useEffect or initialization code
useStore.subscribe(
state => state.count,
(count, prev) => { localStorage.setItem('count', count); },
{ equalityFn: (a, b) => a === b }
);
Note the signature difference: hook useStore(selector, equalityFn) vs middleware subscribe(selector, callback, { equalityFn }). The middleware runs callbacks outside React, so no render is triggered.
TypeScript Guardrails
Custom equality functions require the selector return type to be concrete. Widening to unknown or any defeats inference and can hide stale-closure bugs.
// Good: concrete return type inferred
const slice = useStore(state => ({ a: state.a, b: state.b }), shallow);
// slice type: { a: number; b: string }
// Risky: explicit annotation widens type
const slice: unknown = useStore(state => ({ a: state.a, b: state.b }), shallow);
// Equality function receives unknown, loses key-level checking
Hover the selector in your IDE. The tooltip should show the exact shape, not unknown. If using a custom equality function, ensure its type parameters match the selector output exactly.
Escalation Criteria
If shallow and deep equality both fail to match expected render behavior:
- Verify every
setcall produces new references for changed paths. Search codebase for.push(,.splice(,delete, or direct property assignment on state objects. - Check for external mutation bypassing Zustand — e.g., a library mutating a stored array reference, or state shared with a non-React subsystem.
- Add the Immer middleware globally and audit setters. Immer will freeze state in development (via
enableES5), throwing on accidental mutation. - If using persist/rehydration, confirm selectors don't fire before hydration completes. Gate with
useStore.getState()._hasHydratedor theonRehydrateStoragecallback to avoid a flash of initial state.
Verification Checklist
- Create a minimal reproduction: store with array field, component selecting
state => ({ items: state.items }), button dispatchingset({ items: [...state.items] })(new reference, same content). Observe render count in Profiler. - Swap to
useStore(selector, shallow)and confirm re-render stops when content unchanged. - Mutate in-place (
state.items.push(newItem)) withoutshallow— component should not re-render. Then apply Immer middleware and verify re-render occurs. - Add
subscribeWithSelectormiddleware, subscribe withequalityFn: shallow, log callback invocations, and compare against hook-based subscription counts. - Check TypeScript inference: selector return type should be concrete, not
unknown.
Limitations
- Shallow equality only compares first-level properties. Nested objects/arrays still trigger re-renders if replaced with new references.
- Deep equality functions add measurable overhead on large state slices; always profile before adopting in production.
- Custom equality functions run on every store update for every subscribed component. Avoid expensive computations inside them.
- The
equalityFnparameter position differs between hook and middleware APIs — verify signatures when copying patterns.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.