Zustand 02 ⚛️ Basic Store — Creating atomic state & action selectors
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
| Pattern | Code | Re-renders When |
|---|---|---|
| Atomic value | (state) => state.count | count changes |
| Atomic action | (state) => state.increment | Never (stable reference) |
| Derived primitive | (state) => state.items.length | Length changes |
| Derived object | useShallow((state) => ({ a, b })) | a or b changes |
| Derived array | useShallow((state) => state.items.filter(...)) | Filtered contents change |
| Whole store | useStore() | Any state change |
Comparison Mechanisms
| Mechanism | Compares | Use For |
|---|---|---|
Default (Object.is) | Reference equality | Primitives and stable references |
useShallow | Shallow contents | Objects and arrays from selectors |
Hook Extraction
| Hook | Returns | Purpose |
|---|---|---|
useCount() | state.count | Reusable atomic selector |
useIncrement() | state.increment | Reusable action selector |
useTotal() | Derived number | Reusable 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Component re-renders on every change | No selector, or object selector without useShallow | Use atomic selectors or useShallow |
| Infinite render loop | Selector returns new object every call | Stabilize with useShallow or separate hooks |
| Derived value stale | Derived value stored in state | Compute in selector |
| Action component re-renders | Selecting state alongside actions | Separate display and control components |
| Selector not re-rendering on change | Comparing with wrong equality | Verify 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
| Item | Value |
|---|---|
| Selector function | (state) => slice passed to the store hook |
| Comparison mechanism | Object.is on the selector’s return value |
| Atomic state selector | Returns a single primitive or stable reference |
| Action selector | Returns a function; stable for store lifetime |
| Derived selector | Computes a value from state; returns primitive |
| Object selector | Requires useShallow to compare contents |
| Array selector | Requires useShallow to compare elements |
| Custom hook | Named wrapper around a selector |
| Display/control separation | Display 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.useShallowcompares 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
itemsbelongs 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.
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!