In Zustand 5, how do you read several fields from a store in one hook call without extra re-renders, and what replaced v4's equality-function argument?
answer
- one selector, several fields
- new object means new reference
- a wrapper that remembers the last result
- v5 hook takes no second argument
- the traditional entry point
basics
~20 sWrap the selector in useShallow from zustand/shallow: it returns the previous object or array when the new one is shallow-equal, so the hook sees a stable reference. v5 dropped the hook's equality argument; createWithEqualityFn in zustand/traditional keeps the old style.
solid answer
~40 sA selector like `(s) => ({ items: s.items, coupon: s.coupon })` builds a new object on every call. The hook `create` returns compares selector results with `Object.is`, so a fresh object always looks changed; in Zustand 5, whose hook sits on `useSyncExternalStore`, that can even loop until React throws "Maximum update depth exceeded". The v5 fix is `useShallow` from `zustand/shallow`: `useCheckoutStore(useShallow((s) => ({ items: s.items, coupon: s.coupon })))`. It keeps the last result in a ref and hands it back whenever the new result is shallow-equal, so the reference stays stable. In v4 you passed `shallow` as a second argument to the hook; v5 removed that argument. Teams migrating a large codebase can import `createWithEqualityFn` from `zustand/traditional`, which needs the `use-sync-external-store` peer dependency.
code
tsx · 19 linesimport { useShallow } from 'zustand/shallow'
import { useCheckoutStore } from './checkout-store'
export function OrderSummary() {
// one hook call, several fields, stable reference while they are unchanged
const { items, coupon, applyCoupon } = useCheckoutStore(
useShallow((s) => ({
items: s.items,
coupon: s.coupon,
applyCoupon: s.applyCoupon,
})),
)
return (
<section>
<p>{items.length} items</p>
<button onClick={() => applyCoupon('SPRING')}>{coupon ?? 'add coupon'}</button>
</section>
)
}go deeper
Know the two safe patterns in v5: one hook call per field, or wrap a multi-field selector in useShallow imported from zustand/shallow.
Explain why a literal object fails Object.is on every read, how useShallow returns the previous reference when a shallow compare passes, and what shallow does and does not compare.
Recognise the v5 migration failure mode, a Maximum update depth exceeded error from an unstable selector or fallback, and choose between useShallow and zustand/traditional for a large codebase.
Set the migration policy: a codemod to useShallow versus a temporary alias to createWithEqualityFn, weighing an extra peer dependency against touching every multi-field selector.
## The problem: a selector that builds a new container A Zustand hook re-renders a component when its selector's result changes, and "changes" means `Object.is` says the new result differs from the previous one. A selector that picks one primitive or one existing reference is therefore cheap and precise. The trouble starts when a component wants several fields at once: ```ts const { items, coupon } = useCheckoutStore((s) => ({ items: s.items, coupon: s.coupon, })) ``` The object literal is new on every call. In Zustand 5 the hook `create` returns is a thin layer over React's `useSyncExternalStore`, which reads the selector's output as the snapshot. A snapshot that is a new reference on every read tells React the store keeps changing, and the v5 migration guide warns that this may produce an infinite loop that ends with React's "Maximum update depth exceeded" error. Even where it does not loop, the component re-renders on every store change, including writes to unrelated fields. ## The v5 answer: `useShallow` `useShallow` is a small hook exported from `zustand/shallow` (also reachable as `zustand/react/shallow`). You wrap the selector with it: ```ts import { useShallow } from 'zustand/shallow' const { items, coupon } = useCheckoutStore( useShallow((s) => ({ items: s.items, coupon: s.coupon })), ) ``` What it does, in order: 1. Runs your selector against the current state. 2. Compares the result with the result it stored last time, using Zustand's `shallow` function. 3. If they are shallow-equal, returns the **stored** object; otherwise stores and returns the new one. Because an equal result now comes back as the very same reference, `Object.is` succeeds and the component neither loops nor re-renders. ## What "shallow-equal" means here Zustand's `shallow` compares one level: - identical references (by `Object.is`) are equal immediately; - two values with different prototypes are unequal; - plain objects are equal when they have the same keys and each value passes `Object.is`; - arrays, `Map`s and `Set`s are compared entry by entry, again with `Object.is`. So `useShallow` works for objects and tuples such as `(s) => [s.items, s.addItem]`, but it does **not** look inside nested values. A selector that returns `{ lines: s.items.map(toLine) }` still creates a new `lines` array every time, which fails the comparison. Derive such values from a stable input, or select the raw array and compute in the component. ## What happened to the equality-function argument | Version | Multi-field selection | Custom comparison | |---|---|---| | Zustand 4 | `useStore(selector, shallow)` | second argument to the hook | | Zustand 5 `create` | `useStore(useShallow(selector))` | not supported by the hook | | Zustand 5 `zustand/traditional` | `useStore(selector, shallow)` | `createWithEqualityFn(creator, defaultEqualityFn)` | Zustand 5's `create` returns a hook whose only parameter is the selector. The migration guide offers two routes. The quick one is to import `createWithEqualityFn` (often aliased as `create`) from `zustand/traditional`, which restores the second argument and is built on `useSyncExternalStoreWithSelector`; it requires installing `use-sync-external-store` as a peer dependency. The idiomatic one is `useShallow`, which needs nothing extra. ## Why `create` and `zustand/traditional` behave differently Zustand 5's `create` hook calls React's `useSyncExternalStore` directly, with `() => selector(api.getState())` as the snapshot function, and nothing in between compares results except `Object.is`. A selector that allocates therefore produces a snapshot that never settles. `createWithEqualityFn` from `zustand/traditional` is built on `useSyncExternalStoreWithSelector` instead, which runs your equality function on the selected value before React sees it; that is why the migration guide offers it as the way to keep the v4 behaviour. ## Choosing between the options - **Separate atomic selectors** — `const items = useStore((s) => s.items)` and `const coupon = useStore((s) => s.coupon)` — are the simplest and need no helper; each result is an existing reference. - **`useShallow`** suits one call that returns a tuple or small object, and it is the direct migration for v4's `shallow` argument. - **A stable fallback** matters too: `(s) => s.discount ?? {}` creates a new object whenever `discount` is missing; hoist the fallback to a module constant. - **`zustand/traditional`** is a migration bridge or a deliberate choice when a custom equality function (for example a deep compare) is really needed. The rule of thumb for code review in v5: any selector that returns a literal object or array, or calls `map`/`filter`, should be either wrapped in `useShallow` or split, and any `??` fallback should point at a constant.
- In Zustand 5, why does useShallow not help with a selector that returns { lines: s.items.map(toLine) }?`shallow` compares only the top level with `Object.is`. The `lines` value is a new array produced by `map` on every call, so it never equals the stored one and `useShallow` returns the new object each time. Select `s.items` and derive lines in the component (optionally with `useMemo`), or keep the derived array in the store.
- A Zustand 5 selector is (s) => s.filters ?? {}. Why can that break, and what is the fix?When `filters` is undefined, the selector returns a fresh `{}` on every read, so the snapshot never stabilises and v5 can loop into "Maximum update depth exceeded". Hoist the fallback to a module-level constant, such as `const EMPTY = {}`, so the same reference is returned every time.
saying these in an interview costs you the question
- In Zustand 5 you still pass shallow as the hook's second argument.
- useShallow deep-compares the selected object, so derived arrays are fine.
- Returning a new object from a selector only costs an extra render, never an error.
- createWithEqualityFn works out of the box without any extra dependency.
- useShallow memoises the selector function itself, not its result.