Diagnosing Unnecessary React Renders from Zustand Selector Misuse
Components re-render on every Zustand update even when their data is unchanged. This guide covers selector granularity, selector stability, return identity, and store shape to diagnose and fix wasted React renders.
10 Mar 2026, 02:34 UTC

Problem: components re-render on every store update even when their data is unchanged
In a React app using Zustand, you see wasted renders in React DevTools Profiler after unrelated actions. The UI feels janky and the Profiler flags many components as "rendered because hooks changed" while the displayed values have not changed. The useful takeaway is that the subscription scope and selector return identity, not the store itself, usually cause this.
Recognizable condition
An interaction that updates one slice, e.g. a cart count, causes a component that only reads user preferences to re-render. Profiler shows the component render time with no props change. Adding a console log in the component effect confirms it runs even though the selected value is the same.
Cause / diagnostic table
| Observed symptom | Likely cause | Why it triggers renders |
|---|---|---|
| All components using the store re-render on any set | useStore(store) without a selector | Subscription is to the whole store; any state change notifies all subscribers. |
| Component re-renders even with a selector for a primitive | Selector function recreated each render | New selector reference causes Zustand to tear down and re-subscribe on each render. |
| Selector targets a primitive but still re-renders | Selector returns a new object/array each call | Zustand compares selected value with ===. New reference is always unequal. |
| Derived data stored in state | Derived values written to store instead of computed | Updates to source data cause derived fields to be set, changing references for unrelated subscribers. |
Ordered checks
- Confirm subscription scope. Inspect the hook call in the component. If it is
useStore(myStore)with no second argument, the component subscribes to the entire store. Check the file where the component is defined. - Verify selector stability. If a selector is defined inline, e.g.
useStore(s => s.user.name)inside the component body, note that the function is recreated each render. Move the selector definition outside the component or memoize it. Stable selector reference avoids unnecessary subscribe/unsubscribe cycles. - Log selector return identity. Temporarily add logging in a component effect to compare previous and current selected values with
Object.is. If the values are deeply equal but references differ, the selector is returning a new object/array. - Review store shape. Look at the store creation. If unrelated slices like
ui,cart, anduserlive in one object and are updated together, anysetthat produces a new root reference will notify all subscribers.
Fixes tied to findings
Replace whole-store subscription with a minimal selector
Use useStore(store, selector) for the smallest slice needed. For a counter display:
// store.ts
import { create } from 'zustand'
export const useStore = create(() => ({
count: 0,
user: { name: 'Ada' }
}))
// Counter.tsx
// Good: selects a primitive
const count = useStore(s => s.count)
Risk: selecting too little can lead to prop drilling if the component needs multiple fields. Compose selectors or split the component.
Keep selector reference stable
Define selectors outside the component so the same function reference is reused.
const selectUserName = (s) => s.user.name
function UserBadge() {
const name = useStore(selectUserName)
return <span>{name}</span>
}
Alternative: memoize with useCallback if the selector must close over props. Changing props should be intentional.
Avoid new object returns from selectors
Returning an object literal creates a new reference on every store change.
// Bad: new object each call
const data = useStore(s => ({ count: s.count }))
// Good: select primitives separately or use shallow compare
import { shallow } from 'zustand/shallow'
const { count, user } = useStore(s => ({ count: s.count, user: s.user }), shallow)
Zustand compares selected values with strict equality by default. The third argument can be an equality function. Shallow comparison is commonly used for object returns. Deep equality is not provided and can hide performance issues.
Compute derived data with selectors, not in store
Keep the store minimal and derive values in the selector. Storing derived data increases churn because set must update it too.
Split large stores when isolation is needed
If unrelated domains frequently update together, consider focused stores: useCartStore, useUserStore. This reduces the blast radius of updates. Trade-off is more boilerplate for cross-store actions and consistency of update patterns.
How to verify the fix
- Record an interaction in React DevTools Profiler. Confirm the previously wasted components no longer render.
- Keep temporary logging that compares previous and current selected values with
Object.is. After the fix, identity should be preserved when the slice is unchanged. - Create a minimal reproduction with two components: one using a granular selector and one using whole store. Observe render differences after a store update that does not affect the granular slice.
Escalation criteria
Escalate for architectural review when:
- Renders persist after selector isolation. This can indicate middleware like immer producing a new root reference on every
set, changing identity semantics for the whole state tree. - Multiple stores need tight cross-store synchronization that breaks unidirectional flow. Consider consolidating boundaries or introducing an explicit event layer.
Limitations: Zustand API surface for middleware and equality checks varies across major versions. Immer middleware mutates drafts and produces new state, which can change identity semantics. Shallow equality is the default selector comparison; deep equality is not provided. Splitting stores improves isolation but increases boilerplate.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.