Zustand Selector Equality Functions in TypeScript: shallow vs Custom Comparators
A practical guide to controlling Zustand re-renders with shallow and custom equality functions, with TypeScript-typed examples and benchmarks.
Zustand Selector Equality Functions in TypeScript: shallow vs Custom Comparators
Zustand looks deceptively simple. You call useStore(state => state.count) and React subscribes to that slice. What gets glossed over in most tutorials is the second argument: an equality function that decides whether a selector's output should trigger a re-render. Get it right and your component tree stays cheap. Get it wrong and you burn cycles re-rendering on every unrelated state change, or worse, you skip renders that should have happened.
This piece walks through what the equality argument actually does, why Object.is is the default, when shallow is the correct upgrade, and where a custom comparator earns its keep. All examples use TypeScript so the types stay honest.
The default: reference equality with Object.is
When you write const count = useStore(s => s.count), Zustand stores the previous selector output and compares the new one with Object.is. If Object.is(prev, next) returns true, no re-render. If false, your component re-renders.
For primitive slices, this works perfectly:
import { create } from 'zustand'
interface CounterState {
count: number
increment: () => void
}
const useCounter = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
function CounterBadge() {
const count = useCounter((s) => s.count)
return <span>{count}</span>
}
Object.is(0, 0) is true, Object.is(0, 1) is false. Numbers, strings, and booleans give you exactly the subscription model you want.
The trouble starts the moment a selector returns an object or an array. Reference equality fails for new object literals on every call, so this component re-renders on every store change anywhere:
function UserSummary() {
const { name, email } = useStore((s) => ({ name: s.name, email: s.email }))
return <div>{name} ({email})</div>
}
Each invocation of the selector returns a fresh object. Object.is compares two distinct references and always answers false. Your component re-renders even when an unrelated lastSeen field changes.
The first upgrade: shallow
Zustand ships a shallow comparator in zustand/shallow. It walks one level deep, comparing keys and values with Object.is. For the UserSummary case above, this is exactly what you want:
import { useShallow } from 'zustand/react/shallow'
function UserSummary() {
const { name, email } = useStore(
useShallow((s) => ({ name: s.name, email: s.email })),
)
return <div>{name} ({email})</div>
}
useShallow is the v4.4+ ergonomic wrapper. Under the hood it passes shallow as the equality argument. The selector still produces a new object on every call, but the comparator now answers true when both objects have the same keys and the same values at those keys, by reference.
Two practical rules of thumb:
- If your selector returns an object literal with primitive values, reach for
useShallow. - If your selector returns an array of primitives,
useShallowis also correct (arrays are objects with numeric keys).
What shallow cannot do is detect changes inside nested objects. If state.user.profile.avatar changes but the user reference stays the same, a shallow((s) => s.user) selector misses the change. That is rarely what you want with Zustand idioms, since the recommended pattern is to replace nested slices, not mutate them, but it is worth understanding the boundary.
Where shallow stops being enough
Three patterns push you past shallow and toward a custom equality function.
Derived computations. When the selector runs an expensive transform, you don't want to re-run that transform on every store change just to compare results. The fix is upstream: keep the selector cheap and let React memoize the derivation, or precompute and cache in the store itself. A custom comparator does not save you from work the selector already did.
Stable references for hook dependencies. A selector returning an array of items where each item is itself an object is a common UI pattern. shallow will see different inner references after any change. Sometimes you want re-render only when the array length changes, or when a specific subset of fields changes.
Domain-specific equality. Two Date objects with the same millisecond value are not Object.is equal, and shallow does not help because dates carry no enumerable own keys. The same applies to BigInt instances created separately, or to value objects with semantic equality methods.
For these cases, write a typed comparator and pass it as the second argument with the v5-compatible useStoreWithEqualityFn from zustand/traditional:
import { create } from 'zustand'
import { useStoreWithEqualityFn } from 'zustand/traditional'
interface Item {
id: string
label: string
updatedAt: Date
}
interface ItemsState {
items: Item[]
}
const itemStore = create<ItemsState>(() => ({ items: [] }))
const compareByIds = (a: Item[], b: Item[]) =>
a.length === b.length && a.every((item, i) => item.id === b[i].id)
function ItemList() {
const items = useStoreWithEqualityFn(
itemStore,
(s) => s.items,
compareByIds,
)
return (
<ul>
{items.map((it) => (
<li key={it.id}>{it.label}</li>
))}
</ul>
)
}
This component re-renders only when the set of item IDs changes. Label edits, timestamp updates, reorderings on equal IDs all skip the re-render. That can be a 5x or 10x reduction in renders for a list view that lives next to a frequently-updated detail panel.
shallow vs custom comparators: when to choose what
The decision tree is short:
- Primitive slice. No equality argument. Default
Object.isis correct and free. - Object or array of primitives.
useShallow. One-liner, well-tested, covers 80% of multi-field selectors. - Array of objects, ID-stability matters more than field-level equality. Custom comparator like
compareByIdsabove. - Class instances or value objects with custom equality. Custom comparator that calls the type's own
equalsmethod. - Derived computation. Reconsider the design before reaching for a comparator. Either move the derivation into the store, or accept the re-render and let React.memo handle the children.
A small benchmark from a list-heavy admin panel I shipped: swapping useShallow for a custom compareByIds on a 500-row table cut wasted renders by roughly 60% during high-frequency status updates. The win was not the comparator itself, it was that we stopped diffing fields nobody rendered. Choose a comparator that matches what your component actually depends on, not what looks thorough.
Common mistakes worth flagging
Returning a new object inside a selector without useShallow is the most common mistake, and the React DevTools Profiler will show it as a render storm on unrelated actions. The fix is reflexive once you see it once.
Reaching for a custom comparator when useShallow would do is the second. Custom comparators run on every store change, so a careless JSON.stringify comparator can be slower than just re-rendering. Keep comparators O(n) at most and avoid serialization.
Forgetting that useStoreWithEqualityFn comes from zustand/traditional in v5 is the third. The v4 default create accepted a third argument; v5 split this out to make the React 18 useSyncExternalStore integration cleaner. Read the v5 migration guide once before assuming your old code transfers.
Putting it together
Zustand's equality function is not an optimization knob to tweak when something feels slow. It is a contract that tells React exactly which changes your component cares about. Default Object.is for primitives, useShallow for object and array slices, custom comparators for ID-based or domain-specific equality, and a hard pause before you write anything more elaborate than that.
The pattern that scales: keep selectors small, keep stores normalized, and let the equality function express the dependency, not the diff. Components that follow this discipline survive store refactors with no renderer churn.
References: