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.

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.
The simplest and most efficient form — one primitive value per call:
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.
If a selector returns an object built on every render, the result is always considered different:
// 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 performs a shallow comparison — comparing each field one by one instead of object references:
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.
Separate selectors → most efficient, longer code
useShallow → convenient, several slices, shallow comparisonFor two to three slices, both are equivalent. Choose useShallow when the number of slices starts to grow and separate selector code feels repetitive.
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:
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:
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.
Manual subscriptions suit global side effects that don't require rendering:
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.
Create selectors as separate functions so they can be tested and reused:
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.
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:
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.