Introduction to Zustand Performance
Zustand has become one of the most popular state management libraries in the React ecosystem, prized for its minimal API and lack of boilerplate. However, as applications grow in complexity, even lightweight state libraries can introduce performance bottlenecks if used incorrectly. Understanding how Zustand handles re-renders under the hood and knowing which optimization techniques to apply can make the difference between a buttery-smooth UI and one that stutters under load.
This tutorial covers the internal mechanics of Zustand's subscription model, practical optimization techniques, common pitfalls, and benchmarking strategies. By the end, you'll have a clear mental model of when and how to optimize your Zustand stores.
How Zustand Handles Re-renders
At its core, Zustand is a small wrapper around React's useSyncExternalStore hook (since v4). Every component that calls useStore subscribes to the entire store. When the store updates, Zustand notifies every subscriber, and each subscriber runs its selector function to determine whether it should re-render.
The default behavior, when you call useStore(store) without a selector, returns the entire state object. Because the state object reference changes on every update, every component subscribed without a selector will re-render on every state change — even if the slice they care about didn't change.
The Default Subscription Model
import { create } from 'zustand'
const useStore = create((set) => ({
count: 0,
name: 'Alice',
increment: () => set((s) => ({ count: s.count + 1 })),
setName: (name) => set({ name }),
}))
function Counter() {
// Subscribes to the ENTIRE store
const { count, increment } = useStore()
return <button onClick={increment}>{count}</button>
}
function NameDisplay() {
// Also subscribes to the entire store — re-renders on count change!
const { name } = useStore()
return <p>{name}</p>
}
In this example, calling increment updates count, which triggers a re-render of both Counter and NameDisplay — even though NameDisplay only displays name. This is the most common performance pitfall in Zustand applications.
Why Performance Matters
Unnecessary re-renders are not always catastrophic. For small applications, the overhead is negligible. But as your component tree grows, the cost compounds:
- Wasted reconciliation: React must diff virtual DOM trees for components whose output hasn't actually changed.
- Expensive child renders: If a re-rendered component has expensive children, those children re-render too unless memoized.
- Layout thrashing: Frequent re-renders can trigger repeated browser layout calculations, causing jank.
- Battery and CPU drain: On mobile devices, unnecessary work directly impacts battery life and responsiveness.
In a real-world dashboard with hundreds of subscribed components, naive Zustand usage can turn a 16ms frame budget into a 60ms render cycle, dropping your frame rate from 60fps to under 20fps.
Core Optimization Techniques
1. Use Selectors to Slice State
The single most important optimization is to always use selectors. A selector is a function that extracts a specific piece of state. Zustand only re-renders the component if the selector's return value changes (using Object.is by default).
function Counter() {
const count = useStore((s) => s.count)
const increment = useStore((s) => s.increment)
return <button onClick={increment}>{count}</button>
}
function NameDisplay() {
const name = useStore((s) => s.name)
return <p>{name}</p>
}
Now NameDisplay only re-renders when name changes. Calling increment no longer affects it.
2. Selecting Multiple Slices with shallow Equality
When you need multiple slices, returning an object literal from a selector creates a new reference every time, defeating the optimization. Zustand provides a shallow equality function for this case.
import { create } from 'zustand'
import { shallow } from 'zustand/shallow'
const useStore = create((set) => ({
count: 0,
name: 'Alice',
increment: () => set((s) => ({ count: s.count + 1 })),
}))
function CombinedDisplay() {
// Without shallow, this object is new every render → always re-renders
const { count, name } = useStore(
(s) => ({ count: s.count, name: s.name }),
shallow
)
return <p>{name}: {count}</p>
}
The shallow comparator performs a shallow equality check on the returned object's properties, so the component only re-renders when count or name actually changes.
3. Use useShallow for Modern Zustand (v4.4+)
Zustand v4.4 introduced useShallow, a hook-based alternative that is more ergonomic and works better with TypeScript inference.
import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'
const useStore = create((set) => ({
count: 0,
name: 'Alice',
items: [],
}))
function CombinedDisplay() {
const { count, name } = useStore(useShallow((s) => ({
count: s.count,
name: s.name,
})))
return <p>{name}: {count}</p>
}
useShallow memoizes the selector result internally, avoiding the need to pass a second argument and providing cleaner type inference.
4. Stabilize Action References
Actions defined in the store are stable by default — they don't change between renders. However, if you create functions inline in selectors or pass them through props, you may lose this stability. Always select actions directly:
// ✅ Good — action reference is stable
const increment = useStore((s) => s.increment)
// ❌ Bad — creates a new function every state change
const handleIncrement = useStore((s) => () => s.increment())
5. Split Stores by Domain
A single monolithic store means every update notifies every subscriber. Splitting stores by domain reduces the subscriber pool for each update.
import { create } from 'zustand'
// Separate stores for separate concerns
const useUserStore = create((set) => ({
user: null,
setUser: (user) => set({ user }),
}))
const useCartStore = create((set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
}))
const useUIStore = create((set) => ({
sidebarOpen: false,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
}))
This approach means a cart update only notifies cart subscribers, not user or UI subscribers. The trade-off is slightly more boilerplate and the need to coordinate cross-store updates manually.
6. Transient Updates for Non-Visual State
Not all state needs to trigger re-renders. For values that components read but don't render directly — like animation timestamps, audio positions, or WebSocket buffers — use the store's subscribe API directly and read state imperatively.
const useStore = create((set) => ({
audioTime: 0,
setAudioTime: (t) => set({ audioTime: t }),
}))
function AudioPlayer() {
const audioRef = useRef(null)
useEffect(() => {
// Subscribe without triggering re-renders
const unsubscribe = useStore.subscribe(
(state) => state.audioTime,
(newTime) => {
if (audioRef.current) {
audioRef.current.currentTime = newTime
}
}
)
return unsubscribe
}, [])
return <audio ref={audioRef} />
}
This pattern is especially powerful for high-frequency updates (60fps+ animations) where React re-renders would be too expensive.
7. Memoize Derived State
If a selector performs expensive computation, memoize it to avoid recomputing on every store notification:
import { useMemo } from 'react'
import { create } from 'zustand'
const useStore = create((set) => ({
items: [],
addItems: (items) => set({ items }),
}))
function ExpensiveList() {
const items = useStore((s) => s.items)
const sortedAndFiltered = useMemo(() => {
return items
.filter((i) => i.active)
.sort((a, b) => a.priority - b.priority)
}, [items])
return (
<ul>
{sortedAndFiltered.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
)
}
For cross-component derived state, consider computing it inside the store itself or using a dedicated selector library like reselect.
Advanced Techniques
Custom Equality Functions
Beyond shallow, you can provide custom equality functions for fine-grained control:
const useStore = create((set) => ({
filters: { category: 'all', minPrice: 0, maxPrice: 1000 },
setFilters: (filters) => set({ filters }),
}))
function ProductList() {
// Only re-render if the category filter changes, ignore price changes
const category = useStore((s) => s.filters.category)
// Or use a custom comparator for deep equality on specific fields
const filters = useStore(
(s) => s.filters,
(a, b) => a.category === b.category && a.minPrice === b.minPrice
)
return <div>{category}</div>
}
Using subscribeWithSelector Middleware
The subscribeWithSelector middleware enhances the subscribe method to support selector-based subscriptions with custom equality:
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
const useStore = create(
subscribeWithSelector((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}))
)
// Outside React components — e.g., in a middleware or service
useStore.subscribe(
(state) => state.count,
(count, prevCount) => {
console.log(`Count changed from ${prevCount} to ${count}`)
},
{ equalityFn: (a, b) => Math.floor(a / 10) === Math.floor(b / 10) }
)
This is useful for side effects, logging, analytics, or syncing state to external systems without involving React's render cycle.
Combining Zustand with React.memo
Even with optimal selectors, child components can re-render if their parent re-renders and passes new props. Combine Zustand selectors with React.memo for maximum protection:
import { memo } from 'react'
const ExpensiveChild = memo(function ExpensiveChild({ id, name }) {
console.log('ExpensiveChild rendered')
return <div>{name}</div>
})
function Parent() {
const items = useStore((s) => s.items)
return (
<div>
{items.map((item) => (
<ExpensiveChild key={item.id} id={item.id} name={item.name} />
))}
</div>
)
}
With memo, ExpensiveChild only re-renders when its own props change, even if Parent re-renders due to an unrelated state update.
Benchmarking Zustand Performance
Measuring Re-renders
The first step in optimization is measurement. Use the React DevTools Profiler to identify which components re-render and why. You can also add a simple render counter:
import { useRef } from 'react'
function useRenderCount(label) {
const count = useRef(0)
count.current++
console.log(`${label} rendered ${count.current} times`)
}
function MyComponent() {
useRenderCount('MyComponent')
const count = useStore((s) => s.count)
return <div>{count}</div>
}
Writing Benchmarks with a Test Harness
For quantitative comparisons, create a benchmark harness that renders many components and measures update time:
import { create } from 'zustand'
import { render } from '@testing-library/react'
const useStore = create((set) => ({
value: 0,
setValue: (v) => set({ value: v }),
}))
function Leaf() {
const value = useStore((s) => s.value)
return <span>{value}</span>
}
function benchmark(label, count) {
const tree = (
<div>
{Array.from({ length: count }, (_, i) => (
<Leaf key={i} />
))}
</div>
)
const start = performance.now()
const { unmount } = render(tree)
const renderTime = performance.now() - start
// Trigger an update
const updateStart = performance.now()
useStore.getState().setValue(1)
const updateTime = performance.now() - updateStart
unmount()
console.log(`${label}: render=${renderTime.toFixed(2)}ms, update=${updateTime.toFixed(2)}ms`)
}
benchmark('100 leaves', 100)
benchmark('1000 leaves', 1000)
benchmark('5000 leaves', 5000)
Expected Performance Characteristics
Based on community benchmarks and real-world testing, here are typical performance characteristics for Zustand v4:
- 100 subscribed components: Updates complete in under 1ms with proper selectors.
- 1,000 subscribed components: Updates take 2-5ms with selectors, 15-30ms without.
- 5,000 subscribed components: Updates take 10-20ms with selectors; without selectors, expect 60ms+ and dropped frames.
- With shallow equality on multi-slice selectors: Adds approximately 0.01ms per subscriber compared to single-value selectors.
These numbers vary by hardware, but the relative differences hold: proper selectors provide a 5-10x improvement at scale, and store splitting compounds that benefit further.
Best Practices Summary
- Always use selectors. Never call
useStore()without a selector function unless the store is tiny and rarely updates. - Use
useShallowfor multi-slice selections. It prevents unnecessary re-renders when selecting object slices. - Split stores by domain. Keep unrelated state in separate stores to minimize notification scope.
- Keep actions stable. Define actions in the store creator, not inline in selectors.
- Use transient subscriptions for high-frequency state. Animation frames, scroll positions, and audio timestamps should not trigger React re-renders.
- Memoize expensive derived computations. Use
useMemoor compute derived state in the store. - Profile before optimizing. Use React DevTools Profiler and render counters to identify actual bottlenecks, not theoretical ones.
- Combine with
React.memofor expensive children. Selectors prevent store-driven re-renders, butmemoprevents parent-driven re-renders. - Avoid storing derived state. Store raw data and compute derived values in selectors or
useMemoto prevent staleness bugs. - Consider middleware performance. Middleware like
persistordevtoolsadd overhead; disable them in production builds where appropriate.
Common Pitfalls
Pitfall 1: Object Spread in Selectors
// ❌ Creates a new object every time — always re-renders
const { a, b } = useStore((s) => ({ a: s.a, b: s.b }))
// ✅ Use useShallow
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })))
// ✅ Or select individually
const a = useStore((s) => s.a)
const b = useStore((s) => s.b)
Pitfall 2: Array Selections Without Equality
// ❌ Arrays from state are stable, but filtered/mapped arrays are not
const activeItems = useStore((s) => s.items.filter((i) => i.active))
// ✅ Select the raw array, then filter in useMemo
const items = useStore((s) => s.items)
const activeItems = useMemo(() => items.filter((i) => i.active), [items])
Pitfall 3: Over-Splitting Stores
While store splitting reduces notification scope, too many tiny stores create coordination overhead and make cross-cutting updates harder. Aim for 3-7 domain-based stores in a typical application rather than dozens of single-value stores.
Conclusion
Zustand's simplicity is its greatest strength, but that simplicity can mask performance issues if you're not deliberate about how components subscribe to state. The good news is that the optimization toolkit is small and consistent: use selectors everywhere, apply useShallow for multi-slice selections, split stores by domain, and reach for transient subscriptions when dealing with high-frequency updates. By profiling early, measuring re-renders, and applying these techniques judiciously, you can build applications that scale from dozens to thousands of subscribed components without breaking your frame budget. Remember that premature optimization is still a trap — profile first, identify real bottlenecks, and then apply the targeted technique that addresses the specific problem. Zustand gives you the tools; the discipline of measurement ensures you use them where they matter most.