typescript.
typescript6 min read

Zustand Selector Equality: Cutting Re-renders Without Memo Hell

How shallow equality and stable selector references in Zustand kill needless re-renders, with patterns for partial state shapes and a comparison to useMemo workarounds.

Zustand Selector Equality: Cutting Re-renders Without Memo Hell

Zustand looks deceptively simple. You write const count = useStore(s => s.count) and it works. Then a teammate writes const { user, settings } = useStore(s => ({ user: s.user, settings: s.settings })) and the component starts re-rendering on every action \u2014 including ones that touch unrelated slices. The bug is not Zustand. The bug is that the default equality check is Object.is, and a fresh object literal fails Object.is against its previous self every render.

Two tools fix this: shallow equality and stable selector references. Used together, they let you pull partial state shapes out of the store without paying the re-render tax. Used incorrectly, they create their own subtle bugs \u2014 selectors that never update, or stale closures that read yesterday's state.

The default equality check is strict

When you call useStore(selector), Zustand subscribes the component to the store. After every state change, it runs the selector and compares the new result to the previous result with Object.is. If they match, no re-render. If they differ, React schedules an update.

Object.is is identity comparison. For primitives this is what you want:

const count = useStore((s) => s.count) // re-renders only when s.count changes

For objects it falls apart, because every selector invocation builds a new object:

const view = useStore((s) => ({ user: s.user, settings: s.settings }))
// new object reference every render \u2192 Object.is fails \u2192 re-render every store update

The component re-renders even when s.user and s.settings are unchanged, because the wrapping object is fresh. Every action anywhere in the store triggers this component, regardless of whether its inputs moved.

Shallow equality is the standard fix

Zustand ships a shallow comparator. Pass it as the second argument to useStore (v4) or use useShallow (v5). It compares object keys and values one level deep with Object.is:

import { useShallow } from 'zustand/react/shallow'

const view = useStore(
  useShallow((s) => ({ user: s.user, settings: s.settings }))
)

Now the component re-renders only when s.user or s.settings changes by reference. Updates to s.unrelatedThing no longer wake it up. The useShallow hook in v5 wraps the selector and applies shallow equality internally, which is cleaner than the v4 two-argument form because it composes naturally with React's hook rules.

For arrays, shallow does the same thing element-wise:

const ids = useStore(useShallow((s) => s.items.map((i) => i.id)))

This is the right tool when you derive a small projection of state. It is the wrong tool when the projection is itself expensive to compute \u2014 shallow doesn't memoize the selector, it only compares its output.

Stable selector references avoid one whole class of bug

A selector defined inline is a fresh function on every render. Zustand handles this fine \u2014 it runs the selector against the current state on every store update. But if you forget to apply equality and the selector returns an object, you get the re-render storm above. And if the selector closes over component state, you can get stale reads.

Hoisting the selector out of the component body gives you a stable reference and forces you to think about its dependencies:

const selectUserSettings = (s: AppState) => ({
  user: s.user,
  settings: s.settings,
})

function Header() {
  const { user, settings } = useStore(useShallow(selectUserSettings))
  return <span>{user.name}</span>
}

This pattern scales. You can write a selectors.ts file alongside your store, type-check selectors against the store shape once, and reuse them across components. Test selectors as plain functions \u2014 pass in a state object, assert on the output. No React, no rendering.

The 80% case: most selectors in a real app project a primitive or a single store branch. Those don't need shallow at all. Reach for shallow only when you need a multi-key object or array projection.

When shallow isn't enough

Shallow stops at one level. If your selector returns nested objects whose inner shape changes, shallow sees the outer object as equal but React still gets stale-looking children downstream:

const view = useStore(
  useShallow((s) => ({ tabs: s.tabs })) // s.tabs is an array of objects
)

If s.tabs[0].title changes but s.tabs itself is mutated immutably (new array, same nested object refs for unchanged tabs), this works. If your store mutates in place \u2014 which Zustand allows but discourages \u2014 shallow will return true even when contents changed. The fix is upstream: write reducers that produce new references for changed branches, the same discipline Redux requires.

For genuinely deep comparisons, write a custom equality function. But this is almost always a code smell. If you need deep equality on state, your state shape is too coupled \u2014 split the store, or select smaller slices in separate useStore calls. Two useStore calls with primitives outperform one useStore call with deep equality 3-4\u00d7 in render budget on hot paths, because each primitive call short-circuits at Object.is.

The useMemo trap

Some teams reach for useMemo to stabilize selector output instead of using shallow:

const view = useStore((s) => ({ user: s.user, settings: s.settings }))
const stable = useMemo(() => view, [view.user, view.settings])

This does not work the way it looks. The selector still produces a new object every store update, the component still re-renders before useMemo runs, and useMemo only stabilizes the reference for downstream consumers. You paid the re-render cost; useMemo just caps the damage further down the tree.

useMemo is for memoizing derived computation that runs after the render decision. Equality functions are for skipping the render decision entirely. The two solve different problems. Pick shallow over useMemo for selector output, and use useMemo for expensive transforms applied to that output.

Patterns that age well

A few rules pay off across a real codebase:

  1. Default to primitive selectors. If you can pull s.count instead of { count: s.count, max: s.max }, do. Two selector calls cost less than one shallow comparison in 95% of cases.
  2. Hoist multi-key selectors. Anything returning an object literal goes in selectors.ts with a typed signature. Test them as pure functions.
  3. Use useShallow only for multi-key projections. Don't reach for it on primitives \u2014 it adds a wrapper for no benefit.
  4. Keep state updates immutable. Shallow equality only works if unchanged branches keep their reference. Mutating a nested object in place breaks every consumer that relies on shallow.
  5. Profile with React DevTools "Highlight updates." A component re-rendering on unrelated actions is a selector equality bug 90% of the time.

The Zustand docs cover the basics at docs.pmnd.rs/zustand/getting-started/introduction, and the source for shallow and useShallow lives in the upstream repo at github.com/pmndrs/zustand \u2014 reading the 30-line implementation of shallow is the fastest way to internalize what it actually compares.

References: