Learning Zustand - Optimization & Debugging
Episode 19 of 23

Learning Zustand - Optimization & Debugging

This episode covers a systematic way to find performance problems and bugs in Zustand: profiling re-renders with React Profiler and Redux DevTools, then common troubleshooting like infinite renders, state that doesn't update, hydration mismatches, and stale closures in subscriptions.

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

Introduction

When an application feels slow or behaves oddly, you need a systematic way to find the root cause. Episode 19 covers the two main tools — React Profiler and Redux DevTools — for tracking re-renders and store updates, then walks through the problems that appear most often in Zustand: infinite renders, state that doesn't update, hydration mismatches, and stale closures.

For each problem we discuss the symptom, cause, and solution so debugging no longer means guessing. The pattern is always the same: measure first, then fix based on data, not assumptions.

Profiling Re-renders

React Profiler

React Profiler in the browser DevTools records each component's render along with its duration. Run the application, perform interactions, then inspect which components render most often and most slowly. A Zustand store appears as a hook inside the components that use it — pay attention to components with selectors that return new objects.

To enable profiling in production, render the application with the React profiling build:

Next.js profiling build
npm run build -- --profile

The command npm run build -- --profile produces a production build with React Profiler enabled, useful for measuring real performance outside development mode.

After the build runs, open React Profiler in DevTools and record one interaction session. Filter for components with the highest total render count, then check the cause one by one: new props, new state, or a parent re-render. This data is far more accurate than guessing which component has a problem.

Redux DevTools for Store Updates

With the devtools middleware, every set appears as an action in Redux DevTools. The diff panel shows which fields changed, and the time-travel button rewinds the state to before the bug happened. This is the fastest way to prove that a store update is the cause of cascading re-renders.

Combine both: React Profiler shows which components render excessively, while Redux DevTools shows which store update triggered it. With these two perspectives, the root cause is almost always visible.

Troubleshooting: Infinite Render

The most common symptom: a component renders continuously until the application hangs. The cause is a selector that returns a new object or array on every render:

JSCause of infinite render
const { count } = useCounter((s) => ({ count: s.count }))

(s) => ({ count: s.count }) creates a new object on every render, so strict equality always fails and React renders again. The solution is to use a primitive selector or useShallow. If it still hangs after using useShallow, check whether an action triggers a set that changes the same state inside the render loop.

Troubleshooting: State Not Updating

The second symptom: the UI doesn't change even though set was called. The cause is usually mutating state directly so the object reference doesn't change. Zustand compares with strict equality — a mutated object is still the same object:

JSUndetected mutation
const setProfile = (name) =>
  set((s) => {
    s.user.name = name
    return s
  })

s.user.name = name changes a property without creating a new object, so React doesn't see a change. The solution is to always return a new object:

JSCorrect immutable update
const setProfile = (name) =>
  set((s) => ({ user: { ...s.user, name } }))

{ ...s.user, name } produces a new reference that strict equality detects. If the object is deeply nested, consider the immer middleware from episode 9 to write a mutable draft.

To verify that the update is detected, compare the state reference before and after set: getState().user must produce a different object after a successful set.

Hydration Mismatch and Stale Closures

Hydration Mismatch

This error appears when the server render differs from the first client render — usually because a store reads localStorage during rendering. The solution was already covered in episode 14: use the useHydrated hook and don't access storage during server rendering.

If the error appears in the browser but not on the server, also check whether a component writes state during rendering. Writing state to a store inside a render function triggers an out-of-sync update that can cause a mismatch — move the write into an event handler or an effect.

Stale Closures in Subscriptions

A subscription that captures old variables from its closure uses outdated values:

JSSubscription with a stale closure
useEffect(() => {
  return useAuthStore.subscribe((state) => {
    if (state.token && state.user.role === 'admin') {
      enableAdminPanel()
    }
  })
}, [])

state.token and state.user.role are read from the subscribe parameter, not from an outer closure, so they're always fresh. The golden rule: inside a listener, read values from the state passed by subscribe, not from outer variables that may be stale.

Alternatively, call getState inside the listener so it always reads the latest value without depending on the parameter:

JSReading the latest value via getState
useEffect(() => {
  return useAuthStore.subscribe(() => {
    const { token, user } = useAuthStore.getState()
    if (token && user.role === 'admin') {
      enableAdminPanel()
    }
  })
}, [])

Another common stale closure example: saving a token from the first render into an outer variable, then reading it inside a listener. Because the listener captures the old variable, its value is never fresh. The solution is to always read from the subscribe parameter or call getState inside the listener.

Closing

Episode 19 completes the debugging toolbox: React Profiler for recording re-renders, Redux DevTools for tracking store updates, plus solutions for four common problems — infinite renders from new objects, state that doesn't update from mutations, hydration mismatches, and stale closures in subscriptions.

Take the time to understand the root cause rather than just masking symptoms — correct fixes come from correct measurements.

Key takeaways:

  • React Profiler records each component's render and duration.
  • Redux DevTools shows every set as an action with a diff.
  • A new object inside a selector causes infinite renders.
  • Direct mutations aren't detected by strict equality.
  • Hydration mismatches are prevented by holding the render until hydrated.
  • Read state from the listener parameter, not from an outer closure.

In the next episode we will discuss the latest stable features of Zustand v5 — the useStore function for React 19, middleware typing improvements, the 2026 release series from v5.0.10 to v5.0.14, and how to verify the latest version in the registry.

Learning Zustand - Optimization & Debugging | Learning Zustand