| | |

Zustand 02 ⚛️ Basic Store — Creating atomic state & action selectors

⏱️ Estimated read: 15 min | |

A Zustand store is only as good as the subscriptions that read from it. A store can hold a hundred values, but if a component subscribes to the entire object, it re-renders on every change regardless of which value it actually uses. The selector is what makes Zustand efficient. It extracts a specific slice of state, and the component subscribes to that slice alone. When unrelated state changes, the component does not re-render. When the selected slice changes, it does. This chapter is about designing that granularity: atomic state values, atomic action selectors, and the composition of selectors into reusable hooks.

The previous chapter created a store and exported a hook. This chapter examines how to consume that hook correctly—how to select one value, how to select one action, how to select multiple values without triggering unnecessary re-renders, and when to extract a selector into a named function. The patterns here apply to every subsequent Zustand chapter, because every feature—middleware, async actions, persistence—depends on selectors to expose state to components.

Key point: The selector function determines re-render behavior. A component re-renders when the selector’s return value changes according to Object.is comparison. If the selector returns a new object on every call—(state) => ({ a: state.a, b: state.b })—the component re-renders on every store update, regardless of whether a or b changed. Selector stability is not an optimization; it is a correctness requirement for performance.


Why atomic state and selectors matter

The whole-store subscription problem.

Calling useStore() without a selector returns the entire state object. Zustand subscribes the component to every change in the store. A counter in the header re-renders when a todo is added, a modal opens, or a user logs in, because the component is watching the whole object and the whole object is a new reference on every update. The selector is the mechanism that narrows the subscription to the data the component actually uses. Without it, Zustand behaves like a global context that re-renders every consumer on every change.

The derived-object problem.

A selector that returns a new object on every call—(state) => ({ count: state.count, name: state.name })—creates a new reference every time the store updates. Zustand compares the previous selector result to the new selector result with Object.is. Two different object literals with the same contents are not the same reference, so Object.is returns false, and the component re-renders even if count and name are unchanged. This is a subtle bug because the component appears to select only two values, but the object wrapper defeats the reference equality check. The fix is either to select each value with a separate hook call or to use useShallow from zustand/react/shallow to compare the object’s contents rather than its reference.

The action-selector problem.

Actions are functions stored in the store. They are created once when the store is initialized and never change. A component that selects an action—useStore((state) => state.increment)—subscribes to a reference that is stable for the lifetime of the store. The component never re-renders due to the action changing, because the action does not change. This makes action selectors the safest subscriptions in Zustand: they give the component access to behavior without subscribing to any state value. The pattern of selecting actions individually, rather than selecting the whole store to get them, is what allows a component to remain completely static while still being able to trigger state changes.


a. Selecting atomic state values

An atomic value is a single primitive or reference that changes independently of other state. The selector returns that value directly, without wrapping it in an object.

import { useCounterStore } from './stores/counter'

function CountDisplay() {
  // Select only the count value
  // Re-renders only when count changes
  const count = useCounterStore((state) => state.count)

  return <p>Count: {count}</p>
}

function NameDisplay() {
  // Select only the name value
  // Re-renders only when name changes
  const name = useCounterStore((state) => state.name)

  return <p>Name: {name}</p>
}

Each hook call creates a separate subscription. CountDisplay and NameDisplay are independent: changing count re-renders only CountDisplay, and changing name re-renders only NameDisplay. This is the atomic model. The store holds multiple values, but each component subscribes to exactly the values it needs.

When a component needs multiple values, the naive approach is to select them in one object. This produces the derived-object problem.

// ❌ INCORRECT: New object on every call, re-renders on every update
const { count, name } = useCounterStore((state) => ({
  count: state.count,
  name: state.name,
}))

The correct approaches are to call the hook once per value, or to use useShallow to compare the object’s contents.

// ✅ CORRECT: Separate hook calls, separate subscriptions
const count = useCounterStore((state) => state.count)
const name = useCounterStore((state) => state.name)

// ✅ CORRECT: useShallow compares object contents, not reference
import { useShallow } from 'zustand/react/shallow'

const { count, name } = useCounterStore(
  useShallow((state) => ({ count: state.count, name: state.name }))
)

The separate-hook approach is the simplest and most predictable. The useShallow approach is appropriate when the object shape is large or when the values are used together frequently. The rule is that the selector must return a stable reference unless the selected values have actually changed.


b. Selecting actions

Actions are functions in the store. They are stable references, so selecting them individually does not cause re-renders.

function CounterControls() {
  // Each action is a stable reference
  // This component never re-renders after mount
  const increment = useCounterStore((state) => state.increment)
  const decrement = useCounterStore((state) => state.decrement)
  const reset = useCounterStore((state) => state.reset)

  return (
    <div>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  )
}

The distinction between selecting state and selecting actions is important for render performance. A component that only triggers actions does not need to re-render when state changes. By selecting actions individually, the component subscribes to references that never change, so its render is limited to the initial mount. A component that displays state must select the state value, and it re-renders when that value changes. Separating the display component from the control component is the pattern that keeps both efficient.

// The display component subscribes to count
function CounterDisplay() {
  const count = useCounterStore((state) => state.count)
  return <p>{count}</p>
}

// The control component subscribes only to actions
function CounterControls() {
  const increment = useCounterStore((state) => state.increment)
  return <button onClick={increment}>+</button>
}

// The parent composes both; neither re-renders unnecessarily
function Counter() {
  return (
    <div>
      <CounterDisplay />
      <CounterControls />
    </div>
  )
}

This composition is the practical application of atomic selectors. The parent does not subscribe to anything. Each child subscribes to exactly what it needs. The store updates propagate only to the components whose selected values changed.


c. Extracting selectors into reusable hooks

When the same selector is used in multiple components, extracting it into a custom hook centralizes the subscription logic and makes the component code simpler.

// selectors/counter.ts
import { useCounterStore } from '../stores/counter'

// A custom hook that selects the count value
export function useCount() {
  return useCounterStore((state) => state.count)
}

// A custom hook that selects the increment action
export function useIncrement() {
  return useCounterStore((state) => state.increment)
}

The custom hook does not change the subscription behavior. It is a named wrapper around the same selector. The benefit is consistency: every component that needs the count imports useCount rather than writing the selector inline. If the selector logic changes—for example, to derive a formatted value—the change is in one place.

A selector can also derive a value from the state. Derived values should not be stored in the store; they should be computed in the selector.

// Deriving a value in the selector
export function useTodoCount() {
  return useTodoStore((state) => state.todos.length)
}

export function useCompletedCount() {
  return useTodoStore(
    (state) => state.todos.filter((todo) => todo.completed).length
  )
}

The derived selector returns a number, which is a primitive. The component re-renders only when the number changes. If a todo is added but the completed count does not change, the useCompletedCount subscribers do not re-render. The computation runs on every store update, but the result comparison prevents unnecessary renders.

A selector that returns an array or object from a derived computation must use useShallow or a memoization library, because the derived collection is a new reference on every call.

// ❌ INCORRECT: New array on every call
const completed = useTodoStore(
  (state) => state.todos.filter((todo) => todo.completed)
)

// ✅ CORRECT: useShallow compares array contents
import { useShallow } from 'zustand/react/shallow'

const completed = useTodoStore(
  useShallow((state) => state.todos.filter((todo) => todo.completed))
)

The useShallow hook compares the previous and next results shallowly: for arrays, it compares each element; for objects, it compares each property. If the contents are equal, the component does not re-render even though the reference is new.


Complete Example Session

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

interface CartItem {
  id: number
  name: string
  price: number
  quantity: number
}

interface CartState {
  items: CartItem[]
  isOpen: boolean
  addItem: (item: Omit<CartItem, 'quantity'>) => void
  removeItem: (id: number) => void
  toggleOpen: () => void
}

export const useCartStore = create<CartState>()((set) => ({
  items: [],
  isOpen: false,
  addItem: (item) =>
    set((state) => {
      const existing = state.items.find((i) => i.id === item.id)
      if (existing) {
        return {
          items: state.items.map((i) =>
            i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
          ),
        }
      }
      return { items: [...state.items, { ...item, quantity: 1 }] }
    }),
  removeItem: (id) =>
    set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
  toggleOpen: () => set((state) => ({ isOpen: !state.isOpen })),
}))

// ============================================
// PART 2: ATOMIC STATE SELECTOR — ITEM COUNT
// ============================================
// Selects only the number of items; re-renders when the count changes
function CartBadge() {
  const itemCount = useCartStore((state) => state.items.length)
  return <span className="badge">{itemCount}</span>
}

// ============================================
// PART 3: ATOMIC STATE SELECTOR — OPEN STATE
// ============================================
// Selects only the boolean; re-renders when the panel opens or closes
function CartPanel() {
  const isOpen = useCartStore((state) => state.isOpen)
  if (!isOpen) return null
  return <div className="panel">Cart contents</div>
}

// ============================================
// PART 4: ACTION-ONLY SELECTOR
// ============================================
// Selects only the toggle action; never re-renders after mount
function CartButton() {
  const toggleOpen = useCartStore((state) => state.toggleOpen)
  return <button onClick={toggleOpen}>Toggle cart</button>
}

// ============================================
// PART 5: DERIVED SELECTOR WITH PRIMITIVE RESULT
// ============================================
// Computes the total price; re-renders only when the total changes
function CartTotal() {
  const total = useCartStore((state) =>
    state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )
  return <p>Total: ${total.toFixed(2)}</p>
}

// ============================================
// PART 6: DERIVED SELECTOR WITH ARRAY RESULT
// ============================================
// Uses useShallow to prevent re-renders when the array contents are equal
import { useShallow } from 'zustand/react/shallow'

function ExpensiveItems() {
  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>
  )
}

// ============================================
// PART 7: COMPOSED COMPONENT
// ============================================
// The parent subscribes to nothing; each child subscribes to its own slice
export function CartWidget() {
  return (
    <div>
      <CartButton />
      <CartBadge />
      <CartPanel />
      <CartTotal />
      <ExpensiveItems />
    </div>
  )
}

The seven parts demonstrate the complete selector taxonomy: atomic state, atomic boolean, action-only, derived primitive, and derived array with shallow comparison. The parent component subscribes to nothing. Each child manages its own subscription and re-renders independently.


Quick Reference

Selector Patterns

PatternCodeRe-renders When
Atomic value(state) => state.countcount changes
Atomic action(state) => state.incrementNever (stable reference)
Derived primitive(state) => state.items.lengthLength changes
Derived objectuseShallow((state) => ({ a, b }))a or b changes
Derived arrayuseShallow((state) => state.items.filter(...))Filtered contents change
Whole storeuseStore()Any state change

Comparison Mechanisms

MechanismComparesUse For
Default (Object.is)Reference equalityPrimitives and stable references
useShallowShallow contentsObjects and arrays from selectors

Hook Extraction

HookReturnsPurpose
useCount()state.countReusable atomic selector
useIncrement()state.incrementReusable action selector
useTotal()Derived numberReusable derived selector

Best Practices

✅ Do This:

// Select one value per hook call
const count = useStore((state) => state.count)               // ✅

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

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

// Extract reusable selectors into custom hooks
export function useCount() { return useStore((s) => s.count) } // ✅

// Derive values in the selector, not in the store
const total = useStore((s) => s.items.reduce(...))           // ✅

// Separate display components from control components
<Display /> <Controls />                                     // ✅

❌ Don’t Do This:

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

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

// Store derived values that can be computed
total: 0    // Stale unless manually updated                  // ❌

// Select the whole store to get one action
const { increment } = useStore()                             // ❌

// Use an inline selector that creates a new function each render
useStore((s) => s.items.map(...))    // Without useShallow    // ❌

// Subscribe to state in a component that only triggers actions
const count = useStore((s) => s.count)    // Unused in a control // ❌

Common Pitfalls

PitfallWhy It HappensFix
Component re-renders on every changeNo selector, or object selector without useShallowUse atomic selectors or useShallow
Infinite render loopSelector returns new object every callStabilize with useShallow or separate hooks
Derived value staleDerived value stored in stateCompute in selector
Action component re-rendersSelecting state alongside actionsSeparate display and control components
Selector not re-rendering on changeComparing with wrong equalityVerify selector returns the changed value

Real-World Examples

1. Atomic count selector

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

2. Atomic action selector

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

3. Boolean selector

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

4. Derived length

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

5. Derived sum

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

6. Object selector with useShallow

const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })))

7. Array selector with useShallow

const completed = useStore(useShallow((s) => s.todos.filter((t) => t.done)))

8. Custom hook for selector

export function useCount() {
  return useStore((state) => state.count)
}

9. Custom hook for action

export function useIncrement() {
  return useStore((state) => state.increment)
}

10. Separate display and control

function Display() { const count = useStore((s) => s.count); return <p>{count}</p> }
function Controls() { const inc = useStore((s) => s.increment); return <button onClick={inc}>+</button> }

Visual

Atomic Subscriptions

┌──────────────────────────────────────────────────────────────┐
│  EACH COMPONENT SUBSCRIBES TO ONE SLICE                      │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ Store: { count, name, increment, decrement }           │  │
│  └────────────────────────────────────────────────────────┘  │
│       │              │              │                        │
│       │              │              │                        │
│       ▼              ▼              ▼                        │
│  ┌─────────┐   ┌─────────┐   ┌──────────────┐                │
│  │ Display │   │ Name    │   │ Controls     │                │
│  │ count   │   │ name    │   │ increment    │                │
│  └─────────┘   └─────────┘   └──────────────┘                │
│                                                              │
│  count changes → only Display re-renders                     │
│  name changes  → only Name re-renders                        │
│  increment is called → Controls never re-renders             │
│                                                              │
│  No component watches the whole store.                       │
└──────────────────────────────────────────────────────────────┘

The Derived-Object Trap

┌──────────────────────────────────────────────────────────────┐
│  WHY (state) => ({ a, b }) RE-RENDERS ON EVERY UPDATE        │
│                                                              │
│  Store update (unrelated value changes):                     │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ Before: state = { a: 1, b: 2, c: 3 }                   │  │
│  │ After:  state = { a: 1, b: 2, c: 4 }                   │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  Selector: (state) => ({ a: state.a, b: state.b })           │
│                                                              │
│  Previous result: { a: 1, b: 2 }    ← reference A            │
│  New result:      { a: 1, b: 2 }    ← reference B            │
│                                                              │
│  Object.is(A, B) → false                                     │
│  → Component re-renders even though a and b are unchanged    │
│                                                              │
│  Fix:                                                        │
│  useShallow((state) => ({ a: state.a, b: state.b }))         │
│  → Compares { a: 1, b: 2 } to { a: 1, b: 2 }                 │
│  → Contents equal → no re-render                             │
└──────────────────────────────────────────────────────────────┘

Display and Control Separation

┌──────────────────────────────────────────────────────────────┐
│  SEPARATING STATE SUBSCRIPTION FROM ACTION SUBSCRIPTION      │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ <Counter>  (subscribes to nothing)                     │  │
│  │                                                        │  │
│  │   ┌──────────────────┐   ┌──────────────────────────┐  │  │
│  │   │ <Display>        │   │ <Controls>               │  │  │
│  │   │ selects: count   │   │ selects: increment       │  │  │
│  │   │ re-renders:      │   │ re-renders:              │  │  │
│  │   │   when count     │   │   never                  │  │  │
│  │   │   changes        │   │                          │  │  │
│  │   └──────────────────┘   └──────────────────────────┘  │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  When count changes:                                         │
│  - Display re-renders (its selected value changed)           │
│  - Controls does not re-render (its action is stable)        │
│  - Counter does not re-render (it subscribes to nothing)     │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Selector function(state) => slice passed to the store hook
Comparison mechanismObject.is on the selector’s return value
Atomic state selectorReturns a single primitive or stable reference
Action selectorReturns a function; stable for store lifetime
Derived selectorComputes a value from state; returns primitive
Object selectorRequires useShallow to compare contents
Array selectorRequires useShallow to compare elements
Custom hookNamed wrapper around a selector
Display/control separationDisplay subscribes to state; control subscribes to actions

Key takeaways:

  • The selector determines re-render behavior. A component re-renders when the selector’s return value changes according to Object.is. Select one value per hook call for the finest granularity.
  • Object selectors require useShallow. A selector returning { a, b } creates a new object on every call, defeating reference equality. useShallow compares the contents and prevents unnecessary re-renders.
  • Actions are stable references. Selecting an action subscribes to a function that never changes. A component that only selects actions never re-renders after mount.
  • Separate display from control. The component that shows state should subscribe to state. The component that triggers actions should subscribe to actions. Neither should subscribe to the whole store.
  • Derive values in selectors, not in the store. A total computed from items belongs in the selector, not in a stored field that must be manually updated. The selector recomputes on every store update, and the result comparison prevents unnecessary renders.
  • Extract selectors into custom hooks. A named useCount() is easier to reuse and change than an inline selector repeated across components.
  • Never call the hook without a selector. useStore() subscribes to the entire state and re-renders on every change. The selector is not optional; it is the mechanism that makes Zustand efficient.

Remember: Zustand’s performance comes from the selector, not from the store. The store is a plain object. The hook subscribes to a slice of that object. The component re-renders when the slice changes. Atomic selectors give the finest granularity: one value per subscription. Action selectors give static subscriptions: the function never changes. Derived selectors compute values without storing them, and useShallow prevents object and array wrappers from defeating the comparison. Design the selectors first, and the render behavior follows.


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!