← Back to DevBytes

Zustand Performance: Optimization Techniques and Benchmarks

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:

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:

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles