How to Optimize Zustand Performance for Frequently Changing State: 6 Proven Strategies
Use selector functions combined with shallow equality checks to subscribe components only to specific state slices, and leverage functional setState batching alongside middleware like subscribeWithSelector to minimize re-renders and computations during high-frequency updates.
When building React applications with the pmndrs/zustand library, state that changes on every animation frame or rapid user interaction can quickly become a performance bottleneck. Implementing patterns derived directly from the core source code ensures your UI remains responsive even under sustained high-frequency updates.
Select Specific State Slices with useStore
The useStore hook in src/react.ts (lines 26-38) bridges the Zustand store to React components using React.useSyncExternalStore. Without a selector, this subscribes the component to the entire store, causing re-renders whenever any property changes. By supplying a selector function, you restrict the subscription to only the returned slice, ensuring the component re-renders solely when that specific data changes.
import create from 'zustand'
import { useShallow } from 'zustand/shallow'
// Store holds a large object, but we only need the counter value
const useStore = create((set) => ({
counter: 0,
largeData: { /* … many fields … */ },
inc: () => set((s) => ({ counter: s.counter + 1 })),
}))
function Counter() {
// Selector returns an object; useShallow prevents re‑render if only other fields change
const { counter, inc } = useStore(
(state) => ({ counter: state.counter, inc: state.inc }),
useShallow
)
return (
<>
<div>{counter}</div>
<button onClick={inc}>+</button>
</>
)
}
Stabilize Selectors with Shallow Equality
When selectors return objects or arrays created inline, Object.is comparisons fail on every render because the reference changes. The shallow utility in src/vanilla/shallow.ts (lines 48-74) iterates through iterables, maps, and plain objects to verify property-wise equality using Object.is. Wrapping your selector with useShallow (re-exported in src/react/shallow.ts) prevents spurious re-renders when values remain identical despite new object references.
Isolate Side Effects with subscribeWithSelector Middleware
For logic that must react to state changes without triggering React renders—such as debouncing, analytics, or localStorage syncing—the subscribeWithSelector middleware in src/middleware/subscribeWithSelector.ts (lines 46-71) exposes a selector-based subscription API. This creates listeners that receive only the derived slice and support custom equality functions, isolating side effects from the UI rendering cycle.
import create from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
const useStore = create(
subscribeWithSelector((set, get) => ({
items: [] as number[],
add: (n: number) => set((s) => ({ items: [...s.items, n] })),
}))
)
// Log the length of the array only when it changes
useStore.subscribe(
(state) => state.items.length,
(len) => console.log('Item count:', len)
)
Batch Rapid Updates with Functional setState
The setState implementation in src/vanilla.ts (lines 66-80) efficiently batches multiple updates. When you pass a function to setState, it receives the current state and returns the next state; the internal Object.is guard ensures listeners only fire when the resolved state actually differs. This allows rapid successive calls—such as drag coordinates updating on mousemove—to coalesce into a single notification cycle.
import create from 'zustand'
const useStore = create((set) => ({
x: 0,
y: 0,
move: (dx: number, dy: number) =>
set((s) => ({ x: s.x + dx, y: s.y + dy })), // single listener call
}))
// Called many times per animation frame
function onMouseMove(e: MouseEvent) {
useStore.getState().move(e.movementX, e.movementY)
}
window.addEventListener('mousemove', onMouseMove)
Preserve Reference Equality with Immer Middleware
Large object spreads in updates force shallow comparison checks to traverse every property. The Immer middleware in src/middleware/immer.ts produces new objects only for mutated branches while preserving reference equality for unchanged data. This structural sharing minimizes the work required by Object.is and shallow equality checks during high-frequency update patterns.
Keep the Store Pure and Computation-Free
Stores should contain only data, not expensive transformations. Computing derived data inside selectors or memoized hooks (useMemo) rather than inside the store prevents unnecessary recalculation on every state access, keeping the core state object lightweight.
Summary
- Use selector functions with
useStore(src/react.ts) to limit component subscriptions to specific state slices - Apply
useShallowfromsrc/vanilla/shallow.tsto prevent re-renders caused by object reference changes - Implement
subscribeWithSelectormiddleware (src/middleware/subscribeWithSelector.ts) for side effects without triggering UI updates - Batch rapid changes using functional
setStateinsrc/vanilla.tsto reduce listener notification frequency - Leverage Immer middleware (
src/middleware/immer.ts) for structural sharing and cheap equality checks - Maintain pure stores by moving derived data calculations outside the state object into selectors
Frequently Asked Questions
How does Zustand prevent unnecessary re-renders by default?
Zustand's core setState logic in src/vanilla.ts uses Object.is to compare the next state against the previous state immediately after resolution. If the values are identical, the update aborts and the listeners collection receives no notifications, naturally preventing React component re-renders when data has not actually changed.
When should I use useShallow versus a custom equality function?
Use the built-in useShallow hook (from src/react/shallow.ts) when your selector returns arrays or objects containing primitive values that require shallow comparison. For complex deep equality checks or domain-specific comparison logic, provide a custom equality function to subscribeWithSelector or as the second argument to useStore.
Can I use Immer and shallow equality together?
Yes. Immer from src/middleware/immer.ts maintains reference equality for unchanged branches of the state tree, which pairs perfectly with useShallow or manual shallow checks. This combination ensures object references remain stable across renders unless the underlying data actually mutates, maximizing the effectiveness of shallow comparisons.
Does functional setState batch updates automatically?
Yes. When calling setState with a function multiple times within the same execution tick, Zustand processes each updater sequentially against the current state but only notifies listeners once after all updaters resolve, provided the final state differs from the original. This batching behavior is inherent to the implementation in src/vanilla.ts (lines 66-80).
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →