| | |

Zustand 03 ⚛️ Reading State — Selecting properties & preventing re-renders

⏱️ Estimated read: 14 min | |

Reading state in Zustand is not a single operation. It is a decision about subscription granularity. Every call to the store hook creates a subscription, and the selector passed to that hook determines exactly which changes trigger a re-render. A component that selects a single number re-renders only when that number changes. A component that selects an object without the correct comparison mechanism re-renders on every store update, and in Zustand v5, that pattern can crash the application with a “Maximum update depth exceeded” error.

The previous chapter established atomic state and action selectors. This chapter focuses on the reading patterns that prevent re-renders: primitive selectors, the multiple-hook approach, static selector functions, and useShallow for derived objects and arrays. The distinction between a selector that returns a stable reference and one that returns a fresh reference on every call is the central concern, because Zustand v5 tightened its comparison behavior to match React’s useSyncExternalStore, and selectors that were merely inefficient in v4 are now incorrect.

Key point: Zustand compares the selector’s return value with the previous value using Object.is by default . A selector that returns a new object or array on every call—through an object literal, .map(), .filter(), or Object.keys()—will always fail this comparison, causing a re-render on every store change. Wrap such selectors in useShallow to compare the contents rather than the reference.


Why selector stability became mandatory in v5

The v4-to-v5 behavioral change.

In Zustand v4, a selector returning a new object caused unnecessary re-renders but did not break the application. In v5, the library adopted React’s native useSyncExternalStore behavior, which requires that the value returned by the selector be referentially stable between renders unless it has actually changed . A selector that returns { a: state.a, b: state.b } produces a new object reference on every call. React interprets this as a state change on every read, which triggers a re-render, which triggers another read, producing the “Maximum update depth exceeded” error . The migration guide explicitly states that v5 requires stable selector outputs, and the fix is useShallow .

The multiple-hook recommendation.

The Zustand documentation recommends using multiple hook calls when a component needs multiple values, rather than selecting them in one object . This is not merely a performance suggestion; it is the safest pattern because each hook call subscribes to exactly one value, and primitives and stable references always pass the Object.is comparison correctly. A component with five atomic selectors creates five subscriptions, each independent, and each re-renders only when its specific value changes.

The static selector pattern.

Defining selectors outside the component—as module-level functions—ensures that the selector reference itself is stable across renders. An inline selector like (state) => state.bears creates a new function on every render, which is harmless for the selector’s execution but adds unnecessary allocation. A static selector defined once and reused is more efficient and makes the subscription intent explicit .


a. Primitive selectors and multiple hooks

The simplest and most reliable reading pattern is to select one primitive value per hook call. Primitives—numbers, strings, booleans—are compared by value, so Object.is(5, 5) is true and the component does not re-render unless the number changes.

import { useBearStore } from './stores/bear'

function BearCounter() {
  // Primitive selector: re-renders only when bears changes
  const bears = useBearStore((state) => state.bears)

  return <h1>{bears} bears around here</h1>
}

function FishCounter() {
  // Independent subscription: re-renders only when fish changes
  const fish = useBearStore((state) => state.fish)

  return <h1>{fish} fish in the pond</h1>
}

When a component needs both a state value and an action, the recommended approach is separate hook calls . The state value subscribes to changes; the action is a stable reference that never triggers a re-render.

function BearControls() {
  // State subscription: re-renders when bears changes
  const bears = useBearStore((state) => state.bears)

  // Action subscription: stable reference, never re-renders
  const increasePopulation = useBearStore((state) => state.increasePopulation)

  return (
    <div>
      <p>Bears: {bears}</p>
      <button onClick={increasePopulation}>Add bear</button>
    </div>
  )
}

This component re-renders when bears changes, but the increasePopulation selector does not contribute to that re-render decision. The separation is clear: the state value drives rendering, and the action provides behavior. Zustand’s hook is optimized for multiple calls in the same component, so the overhead of two subscriptions is negligible compared to the correctness of the subscription boundaries .


b. Object selectors and useShallow

When a component genuinely needs multiple values from the store, the object selector is tempting. Without useShallow, it is a bug in v5.

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

function BearStats() {
  // Without useShallow, this re-renders on every store change
  // With useShallow, it re-renders only when bears or honey changes
  const { bears, honey } = useBearStore(
    useShallow((state) => ({ bears: state.bears, honey: state.honey }))
  )

  return (
    <div>
      Bears: {bears}, Honey: {honey}
    </div>
  )
}

useShallow performs a shallow comparison of the returned object: it checks each top-level property with Object.is. If bears and honey are both the same as the previous render, the hook returns the previous object reference, and the component does not re-render . The shallow comparison is one level deep; nested objects are compared by reference and will still fail the equality check unless they are the same reference.

The same pattern applies to arrays. A selector that returns state.items.map((item) => item.id) produces a new array on every call. Wrapping it in useShallow compares the elements and prevents re-renders when the IDs are unchanged .

function TodoIds() {
  // Without useShallow: new array every call, re-renders on every update
  // With useShallow: compares array elements, re-renders only when IDs change
  const ids = useTodoStore(
    useShallow((state) => state.todos.map((todo) => todo.id))
  )

  return <ul>{ids.map((id) => <TodoItem key={id} id={id} />)}</ul>
}

The documentation explicitly lists the cases where useShallow is required: selecting arrays of values, selecting objects with multiple properties, and any computed value that returns a new reference with shallow-equal contents . It should be skipped for single primitive values and single object references that are already stable.


c. Derived values and stable fallbacks

Derived values—totals, counts, filtered lists—should be computed in the selector, not stored in the store. A primitive derived value requires no special handling because the result is compared by value.

function CartTotal() {
  // Primitive result: compared by value, no useShallow needed
  const total = useCartStore((state) =>
    state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  return <p>Total: ${total.toFixed(2)}</p>
}

A derived array or object requires useShallow for the same reason an object literal does: the computation produces a new reference on every call. A filtered list is a new array even if the filter predicate matched the same elements.

function CompletedTodos() {
  // Derived array: useShallow compares contents
  const completed = useTodoStore(
    useShallow((state) => state.todos.filter((todo) => todo.done))
  )

  return <ul>{completed.map((todo) => <li key={todo.id}>{todo.text}</li>)}</ul>
}

A subtle trap is the inline fallback. A selector like (state) => state.action ?? (() => {}) creates a new function every time the fallback branch is taken. The function is a new reference, and the comparison fails. The fix is to define the fallback outside the selector as a module-level constant .

// ❌ New function every call when action is undefined
const action = useStore((state) => state.action ?? (() => {}))

// ✅ Stable fallback reference
const FALLBACK_ACTION = () => {}
const action = useStore((state) => state.action ?? FALLBACK_ACTION)

The same principle applies to empty arrays and objects. (state) => state.items ?? [] creates a new array on every call when items is undefined. A module-level EMPTY_ARRAY constant provides a stable reference that passes the comparison .


Complete Example Session

// ============================================
// PART 1: THE STORE
// ============================================
import { create } from 'zustand'

interface CartItem { id: number; name: string; price: number; quantity: number }
interface CartState {
  items: CartItem[]
  isOpen: boolean
  discount: number
  addItem: (item: CartItem) => void
  toggleOpen: () => void
}

export const useCartStore = create<CartState>()((set) => ({
  items: [],
  isOpen: false,
  discount: 0,
  addItem: (item) => set((s) => ({ items: [...s.items, item] })),
  toggleOpen: () => set((s) => ({ isOpen: !s.isOpen })),
}))

// ============================================
// PART 2: STATIC SELECTOR DEFINITIONS
// ============================================
// Defined at module level: stable reference across renders
const selectItemCount = (state: CartState) => state.items.length
const selectIsOpen = (state: CartState) => state.isOpen
const selectToggleOpen = (state: CartState) => state.toggleOpen

// ============================================
// PART 3: PRIMITIVE SELECTORS
// ============================================
function CartBadge() {
  // Re-renders only when item count changes
  const count = useCartStore(selectItemCount)
  return <span>{count}</span>
}

function CartVisibility() {
  // Re-renders only when isOpen changes
  const isOpen = useCartStore(selectIsOpen)
  return isOpen ? <div>Cart is open</div> : null
}

// ============================================
// PART 4: ACTION SELECTOR
// ============================================
function CartToggle() {
  // Stable reference: never re-renders after mount
  const toggle = useCartStore(selectToggleOpen)
  return <button onClick={toggle}>Toggle</button>
}

// ============================================
// PART 5: MULTIPLE HOOKS FOR MULTIPLE VALUES
// ============================================
function CartSummary() {
  // Two independent subscriptions
  const count = useCartStore(selectItemCount)
  const discount = useCartStore((state) => state.discount)

  return (
    <p>
      {count} items, discount: {discount}%
    </p>
  )
}

// ============================================
// PART 6: OBJECT SELECTOR WITH useShallow
// ============================================
import { useShallow } from 'zustand/react/shallow'

function CartHeader() {
  // useShallow compares count and discount
  // Re-renders only when either value changes
  const { count, discount } = useCartStore(
    useShallow((state) => ({
      count: state.items.length,
      discount: state.discount,
    }))
  )

  return <h2>{count} items ({discount}% off)</h2>
}

// ============================================
// PART 7: DERIVED ARRAY WITH useShallow
// ============================================
function ExpensiveItems() {
  // Filter produces a new array; useShallow compares contents
  const expensive = useCartStore(
    useShallow((state) => state.items.filter((item) => item.price > 100))
  )

  return (
    <ul>
      {expensive.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  )
}

The seven parts demonstrate the reading patterns from safest to most complex: static primitive selectors, action selectors, multiple hooks, object selectors with useShallow, and derived arrays with useShallow. The static selector definitions in Part 2 are a small optimization that also makes the subscription intent explicit.


Quick Reference

Selector Types and Re-render Behavior

Selector PatternComparisonRe-renders When
(state) => state.countObject.iscount changes
(state) => state.actionObject.isNever (stable reference)
useShallow((s) => ({ a, b }))Shallowa or b changes
useShallow((s) => s.items.map(...))ShallowArray contents change
(s) => ({ a, b }) without useShallowObject.isEvery store change (v5: crash)

When to Use useShallow

Use useShallowSkip useShallow
Object with multiple propertiesSingle primitive value
Array from .map(), .filter(), Object.keys()Single stable object reference
Inline object/array fallbackAction selector

Common Stable Fallbacks

const EMPTY_ARRAY: never[] = []
const EMPTY_OBJECT = {}
const FALLBACK_ACTION = () => {}

Best Practices

✅ Do This:

// Use static selectors defined outside the component
const selectCount = (state: State) => state.count                    // ✅
const count = useStore(selectCount)                                  // ✅

// Use multiple hooks for multiple primitive values
const count = useStore((s) => s.count)                               // ✅
const name = useStore((s) => s.name)                                 // ✅

// Wrap object selectors in useShallow
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })))   // ✅

// Wrap derived arrays in useShallow
const ids = useStore(useShallow((s) => s.items.map((i) => i.id)))    // ✅

// Use stable constants for fallbacks
const EMPTY: never[] = []                                            // ✅
const items = useStore((s) => s.items ?? EMPTY)                      // ✅

// Select actions individually
const increment = useStore((s) => s.increment)                       // ✅

❌ Don’t Do This:

// Call the hook without a selector
const store = useStore()                                             // ❌

// Return an object without useShallow
const { a, b } = useStore((s) => ({ a: s.a, b: s.b }))              // ❌ v5 crash

// Return a derived array without useShallow
const done = useStore((s) => s.todos.filter((t) => t.done))          // ❌ v5 crash

// Use an inline fallback that creates a new reference
const action = useStore((s) => s.action ?? (() => {}))               // ❌

// Select the whole store to destructure one value
const { count } = useStore()                                         // ❌

// Assume v4 behavior carries over to v5
// The comparison is stricter; unstable selectors now crash              // ❌

Common Pitfalls

PitfallWhy It HappensFix
“Maximum update depth exceeded”Object/array selector without useShallowWrap in useShallow
Component re-renders on every changeNo selector, or unstable object selectorUse primitive selectors or useShallow
Fallback creates infinite loop?? [] or ?? (() => {}) in selectorModule-level constant
useShallow not importedImported from wrong pathimport { useShallow } from 'zustand/react/shallow'
Selector returns nested objectuseShallow only compares one levelSelect primitives or use deep equality
v4 code crashes in v5Unstable selector outputMigrate to useShallow or stable references

Real-World Examples

1. Primitive selector

const count = useStore((state) => state.count)

2. Static selector definition

const selectCount = (state: State) => state.count
const count = useStore(selectCount)

3. Action selector

const increment = useStore((state) => state.increment)

4. Multiple hooks

const count = useStore((s) => s.count)
const name = useStore((s) => s.name)

5. Object selector with useShallow

const { count, name } = useStore(useShallow((s) => ({ count: s.count, name: s.name })))

6. Derived array with useShallow

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

7. Stable fallback constant

const EMPTY: never[] = []
const items = useStore((s) => s.items ?? EMPTY)

8. Derived primitive

const total = useStore((s) => s.items.reduce((sum, i) => sum + i.price, 0))

9. Boolean selector

const isOpen = useStore((s) => s.isOpen)

10. v5 migration fix

// Before (v4): useStore((s) => [s.a, s.b])
// After (v5): useStore(useShallow((s) => [s.a, s.b]))

Visual

Selector Comparison: Object.is vs Shallow

┌──────────────────────────────────────────────────────────────┐
│  WHY useShallow IS REQUIRED FOR OBJECTS AND ARRAYS           │
│                                                              │
│  Selector: (state) => ({ a: state.a, b: state.b })           │
│                                                              │
│  Store update (unrelated value changes):                     │
│  Before: { a: 1, b: 2, c: 3 }                                │
│  After:  { a: 1, b: 2, c: 4 }                                │
│                                                              │
│  Previous result: { a: 1, b: 2 }  ← reference A              │
│  New result:      { a: 1, b: 2 }  ← reference B              │
│                                                              │
│  Object.is(A, B) → false                                     │
│  → Re-render (or infinite loop in v5)                        │
│                                                              │
│  With useShallow:                                            │
│  Shallow compare A and B:                                    │
│    a: 1 === 1 ✓                                              │
│    b: 2 === 2 ✓                                              │
│  → Contents equal → return reference A → no re-render        │
└──────────────────────────────────────────────────────────────┘

Multiple Hooks vs Object Selector

┌──────────────────────────────────────────────────────────────┐
│  TWO WAYS TO SELECT MULTIPLE VALUES                          │
│                                                              │
│  Multiple hooks (recommended for primitives):                │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ const count = useStore((s) => s.count)                 │  │
│  │ const name  = useStore((s) => s.name)                  │  │
│  │                                                        │  │
│  │ Two subscriptions, independent comparison              │  │
│  │ count changes → only count subscription fires          │  │
│  │ name changes  → only name subscription fires           │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  Object selector with useShallow:                            │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ const { count, name } = useStore(                       │  │
│  │   useShallow((s) => ({ count: s.count, name: s.name }))│  │
│  │ )                                                      │  │
│  │                                                        │  │
│  │ One subscription, shallow comparison                   │  │
│  │ Either value changes → component re-renders            │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  Both are correct. Multiple hooks give finer control.        │
└──────────────────────────────────────────────────────────────┘

The v5 Stability Requirement

┌──────────────────────────────────────────────────────────────┐
│  v4 vs v5 SELECTOR BEHAVIOR                                  │
│                                                              │
│  v4:                                                         │
│  Selector returns new object every call                      │
│  → Unnecessary re-renders                                     │
│  → Warning in console                                         │
│  → Application continues                                      │
│                                                              │
│  v5:                                                         │
│  Selector returns new object every call                      │
│  → React's useSyncExternalStore detects unstable snapshot    │
│  → "The result of getSnapshot should be cached" warning       │
│  → "Maximum update depth exceeded" error                      │
│  → Application crashes                                        │
│                                                              │
│  Fix: useShallow, or select primitives individually          │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Default comparisonObject.is on selector return value
Primitive selectorStable by value; no useShallow needed
Action selectorStable reference; never triggers re-render
Object selectorRequires useShallow in v5
Derived arrayRequires useShallow
useShallow importzustand/react/shallow
Inline fallbackNew reference every call; use module constant
Multiple valuesPrefer multiple hooks for primitives
v5 changeUnstable selectors crash; v4 only warned
Static selectorDefined outside component for stable reference

Key takeaways:

  • The selector determines what the component watches. A primitive selector subscribes to one value. An object selector without useShallow subscribes to a reference that changes on every update, which in v5 is a crash, not just a performance issue .
  • useShallow is the fix for objects and arrays. It compares the contents rather than the reference, returning the previous reference when the contents are unchanged. Import it from zustand/react/shallow .
  • Multiple hooks are the safest pattern for multiple values. Each hook subscribes to one value. The subscriptions are independent, and primitives always compare correctly. Zustand’s hook is optimized for multiple calls in the same component .
  • Derived values belong in selectors, not in the store. A total, count, or filtered list should be computed in the selector. Primitive results need no special handling. Array and object results need useShallow.
  • Inline fallbacks create new references. ?? [] and ?? (() => {}) produce fresh values on every call. Define stable constants at module scope and reference them in the selector .
  • v5 requires stable selector outputs. The migration guide explicitly states that selectors returning new references cause infinite loops. The fixes are useShallow or createWithEqualityFn from zustand/traditional for v4 compatibility .
  • Static selectors are a small optimization with a large clarity benefit. Defining const selectCount = (state) => state.count outside the component makes the subscription intent explicit and avoids recreating the function on every render .

Remember: Reading state in Zustand is a subscription decision. The selector is the subscription. A primitive selector says “I watch this one value.” An object selector without useShallow says “I watch a new object every time,” which is not a valid subscription in v5. Use multiple hooks for primitives, useShallow for objects and arrays, and stable constants for fallbacks. The comparison is Object.is by default, and in v5 that comparison is enforced. Design the selectors around what the component actually displays, and the re-renders follow the data, not the store.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!