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.

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.
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:
npm run build -- --profileThe 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.
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.
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:
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.
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:
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:
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.
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.
A subscription that captures old variables from its closure uses outdated values:
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:
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.
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:
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.