Preventing Unnecessary Re-renders with Zustand's Shallow Equality
Learn how Zustand's shallow equality helper stops wasteful re-renders, when to use custom equality functions, and how to verify the impact with React DevTools.
11 Apr 2026, 06:16 UTC

The problem: selectors create new references on every state change
Zustand’s useStore hook runs your selector function on each state update. If the selector returns an object or array, a brand‑new reference is produced even when the contained data hasn’t changed. Child components that consume that selector will re‑render because React’s default reference equality check sees a different value.
How shallow equality fixes it
The shallow helper (exported from zustand/shallow since v4.4) performs a one‑level equality comparison on the selector result. When top‑level keys and primitive values are identical, the hook skips the re‑render. This is a drop‑in replacement for the default reference check.
import { create } from 'zustand'
import { shallow } from 'zustand/shallow'
const useStore = create((set) => ({
items: [] as string[],
addItem: (item: string) => set((state) => ({ items: [...state.items, item] })),
}))
// Component that only cares about the length
function ItemCount() {
const length = useStore((state) => state.items.length, shallow)
return {length}
}
Without shallow, ItemCount would re‑render on every addItem call because the selector returns a new number primitive each time (numbers are compared by value, but the selector function itself is recreated each render). With shallow, the comparison sees the same numeric value and bails out.
Worked example: mutating an unrelated array element
Consider a store that holds an array of objects. A component selects the whole array to render a list, while another component only needs the first item’s id.
const useStore = create((set) => ({
users: [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Grace' },
],
updateName: (id: number, name: string) =>
set((state) => ({
users: state.users.map((u) => (u.id === id ? { ...u, name } : u)),
})),
}))
// Selects the first user's id only
function FirstUserId() {
const id = useStore((state) => state.users[0]?.id, shallow)
return First user id: {id}
}
// Selects the entire array for a list
function UserList() {
const users = useStore((state) => state.users, shallow)
return (
{users.map((u) => (
- {u.name}
))}
)
}
When updateName(2, 'Grace Hopper') runs, UserList receives a new array reference (because map returns a new array) and correctly re‑renders. FirstUserId receives the same id value (1) and, thanks to shallow, does not re‑render.
When shallow isn’t enough – custom equality functions
Shallow equality only looks one level deep. If your selector returns a nested object and you mutate a deeply nested property without changing the top‑level reference, shallow will treat the result as unchanged and the component won’t update. For those cases you can pass a custom equality function as the second argument to useStore:
import { isEqual } from 'lodash-es'
function DeepComponent() {
const data = useStore(
(state) => state.nested,
(a, b) => isEqual(a, b) // deep comparison
)
// …
}
The custom function must be pure, synchronous, and return true when the two values are considered equal. Async or side‑effecting comparisons break Zustand’s subscription guarantees.
Trade‑off: performance cost of shallow on large objects
Shallow comparison iterates over all own enumerable keys of the selector result. On very large objects or arrays this can be slower than a simple reference check. Before applying shallow broadly, profile the component with React DevTools Profiler:
- Record a session that triggers the state update.
- Compare the flamegraph with and without
shallow. - If the “self time” of the selector or the equality function dominates, consider a more targeted selector (e.g., pick only the needed fields) or a memoized selector defined outside the component.
Also note that inline arrow‑function selectors create a new function reference each render, defeating memoization. Define the selector outside the component or wrap it in useCallback.
Actionable next step
Identify a component that re‑renders on unrelated state changes. Replace its useStore(selector) call with useStore(selector, shallow) (ensure you import from zustand/shallow on v4.4+). Run a quick DevTools profile to confirm the render count drops. If the selector returns a large object, measure the equality cost first; otherwise, you’ve eliminated a class of unnecessary renders with a one‑line change.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.