Optimizing React Renders with Zustand Selectors and Shallow Equality
Learn how Zustand’s selector‑based subscriptions and shallow equality can cut unnecessary renders in React components, with a concrete example and best‑practice checklist.
07 Jul 2025, 02:43 UTC

Problem: Every store change floods the UI
In a typical React app using Zustand, a component that calls useStore() will re‑render whenever *any* field in the store changes. If the store holds dozens of unrelated values, a single unrelated update can cause dozens of components to re‑render, hurting performance and user experience.
The core of Zustand’s performance promise is that you can pick a slice of state with a selector and let the hook only re‑render when that slice changes. This blog explains how that works, how to use it correctly, and what pitfalls to avoid.
How Zustand’s selector works
The useStore hook accepts an optional selector function:
const count = useStore(state => state.count);
The selector runs on every store update. Zustand keeps a reference to the previous selector result and, by default, compares the new result to the old one with shallow equality (a shallow === check for primitives and a shallow reference check for objects/arrays). If the reference is the same, the component is not re‑rendered.
Shallow equality is a cheap operation that catches most common cases where a piece of state is unchanged. It’s not a deep compare, so mutating a nested field inside an object will still trigger a re‑render.
Keeping selectors stable
When you write a selector inline inside a component, a new function is created on every render. Zustand treats that as a new selector, unsubscribes the old one, and resubscribes the new one. The churn can outweigh the benefits of selective subscription.
Best practice: extract the selector to a stable reference or wrap it in useCallback.
// Stable selector outside the component
const selectCount = state => state.count;
function Counter() {
const count = useStore(selectCount);
// …
}
Alternatively, if you need a selector that depends on props, use useCallback with the correct dependency array.
Concrete example: two independent fields
Below is a minimal setup that demonstrates how selecting one field protects a component from re‑renders caused by updates to another field.
import create from 'zustand';
// 1. Store with two independent fields
const useStore = create(set => ({
a: 1,
b: 2,
setA: val => set(state => ({ ...state, a: val })),
setB: val => set(state => ({ ...state, b: val })),
}));
// 2. Component that only cares about `a`
function ComponentA() {
const a = useStore(state => state.a); // selector
console.log('Render A', a); // log to see when it renders
return <div>A: {a}</div>;
}
// 3. Component that only cares about `b`
function ComponentB() {
const b = useStore(state => state.b); // selector
console.log('Render B', b);
return <div>B: {b}</div>;
}
// 4. Update `b` and observe that ComponentA does not re‑render
useStore.getState().setB(42); // only ComponentB logs a new render
In practice you would mount ComponentA and ComponentB in your app. After calling setB, the console will show a re‑render for ComponentB only. If you had omitted the selector and called useStore() without arguments, both components would re‑render.
Trade‑offs and limitations
- Nested objects: If a selector returns an object, any mutation that changes the object’s reference will trigger a re‑render, even if the nested fields you care about didn’t change. Use immutable updates or pick primitive slices.
- Selector churn: Inline selectors or non‑memoized functions cause resubscription each render, which can be costly in large trees.
- Middleware interactions: Persist middleware hydrates asynchronously. A component may first render with an empty state, then re‑render once hydration finishes. The selector will fire twice in that case, which is expected.
- Version sensitivity: The default equality function is shallow. Future releases might expose a custom equality option; keep an eye on the changelog if you need deeper comparison.
Checklist for production use
- Define selectors as stable functions outside the component or memoize with
useCallback. - Prefer primitives or shallow objects for selector results.
- Measure render counts in development (e.g., using
React.Profileror console logs). - When using middleware that hydrates asynchronously, be prepared for an initial render with default state.
- Keep an eye on Zustand version changes; re‑visit selector behaviour if you upgrade.
Actionable takeaway
Start by scanning your components that call useStore(). If they subscribe to the whole store, replace the call with a selector that pulls only the fields you need. Extract the selector to a stable reference, and test that unrelated updates no longer trigger a re‑render. This small change often yields a noticeable performance boost, especially in large UI trees.
Remember: selectors are a developer‑controlled optimization. Use them thoughtfully, watch for nested mutation pitfalls, and you’ll keep your React app snappy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.