Learning Zustand - Selectors & Subscriptions
Episode 5 of 23

Learning Zustand - Selectors & Subscriptions

This episode covers selectors as the key to minimal re-renders, using useShallow from zustand/react/shallow to select several slices at once, and manual subscriptions with subscribeWithSelector for logging, analytics, and synchronization outside React.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Selectors are the heart of Zustand's selective re-rendering — the ability to read part of the state without triggering a render when other parts change. Episode 5 covers them thoroughly: the single-selector pattern, the trap of selectors returning new objects, the useShallow solution, and manual subscriptions for needs outside React.

You'll understand when to use selectors in hooks and when a manual subscription is more appropriate. This is also the performance foundation we'll optimize further in episode 13.

Selectors: Choosing a State Slice

Single Selector

The simplest and most efficient form — one primitive value per call:

JSPrimitive value selector
const count = useCounter((s) => s.count)
const name = useProfile((s) => s.profile.name)

useCounter((s) => s.count) returns a primitive. Because primitives are compared with ===, the component only re-renders when the value truly changes. This is the most recommended pattern.

The New Object Selector Trap

If a selector returns an object built on every render, the result is always considered different:

JSIncorrect new object selector
// BAD: a new object every render causes infinite re-renders
const { count, status } = useCounter((s) => ({ count: s.count, status: s.status }))

useCounter((s) => ({ count: s.count, status: s.status })) creates a new object every time it's called, so strict equality always fails and the component re-renders endlessly. The solution: call separate selectors, or use useShallow.

useShallow

useShallow performs a shallow comparison — comparing each field one by one instead of object references:

JSuseShallow for several slices
import { useShallow } from 'zustand/react/shallow'
 
const { count, status } = useCounter(
  useShallow((s) => ({ count: s.count, status: s.status })),
)

useShallow((s) => ({ count: s.count, status: s.status })) is imported from zustand/react/shallow. The component re-renders only when count or status changes. Note: in v5 the import lives in zustand/react/shallow, no longer zustand/shallow.

useShallow vs Separate Selectors

Choose your approach
Separate selectors → most efficient, longer code
useShallow      → convenient, several slices, shallow comparison

For two to three slices, both are equivalent. Choose useShallow when the number of slices starts to grow and separate selector code feels repetitive.

Manual Subscription

subscribe with subscribeWithSelector

By default subscribe(listener) only accepts a single listener. To subscribe with a selector and a listener that receives two arguments, enable the subscribeWithSelector middleware:

JSEnabling subscribeWithSelector
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
 
export const useUserStore = create(
  subscribeWithSelector((set) => ({
    name: '',
    role: 'viewer',
    setName: (name) => set({ name }),
  })),
)

With the middleware enabled, subscribe(selector, listener) becomes available:

JSSubscribe with a selector
const unsub = useUserStore.subscribe(
  (s) => s.role,
  (role, prevRole) => {
    console.log(`role berubah: ${prevRole}${role}`)
  },
)

useUserStore.subscribe((s) => s.role, listener) calls the listener only when s.role changes, and the unsub function stops the subscription. Call unsub in the useEffect cleanup so it doesn't leak.

Use Case: Logging and Analytics

Manual subscriptions suit global side effects that don't require rendering:

JSLogging state changes to analytics
const unsub = useUserStore.subscribe(
  (s) => s.name,
  (name) => analytics.track('user_name_updated', { name }),
)

This pattern is used for native synchronization, log recording, or calling an API when a specific slice changes — all without triggering a component render. analytics.track(...) here is a placeholder for your analytics service.

Other Practical Patterns

Reusable Selectors

Create selectors as separate functions so they can be tested and reused:

JSReusable selectors
export const selectCount = (s: CounterState) => s.count
 
const count = useCounter(selectCount)

A named selector selectCount improves readability and makes unit testing easier — a pattern used again in episode 18.

Closing

Episode 5 locks down your understanding of selectors and subscriptions: single selectors for minimal re-renders, useShallow for several slices with shallow comparison, and subscribeWithSelector for side effects outside React.

Key takeaways:

  • Primitive selectors are compared with ===, so re-renders stay minimal.
  • A selector returning a new object on every render triggers infinite re-renders.
  • useShallow from zustand/react/shallow compares objects shallowly.
  • subscribeWithSelector enables subscribe(selector, listener).
  • Manual subscriptions suit logging, analytics, and non-React synchronization.
  • Always call unsub in the useEffect cleanup to avoid leaks.

In the next episode we will discuss actions and async state — putting logic inside the store, async actions with async/await, managing loading/error/success status, and centralized error handling. Real application state is almost always asynchronous.

Learning Zustand - Selectors & Subscriptions | Learning Zustand