Diagnosing Zustand State Update Issues in React Apps
Learn why Zustand subscribers may miss updates and how to diagnose middleware, mutation, closure, and subscription issues with step‑by‑step checks and fixes.
05 Aug 2025, 10:19 UTC

Recognizable condition
Components that subscribe to a Zustand store with useStore or useShallow do not re‑render after calling store.set. The UI shows stale values even though logging store.getState() immediately after the set call displays the expected new state.
Cause / diagnostic table
| Symptom | Likely cause |
|---|---|
| UI unchanged, store logs new value | Selector recreated on each render breaking shallow equality |
| UI unchanged, store logs old value | Direct mutation of state (no immer) or stale closure capturing previous state |
| No action appears in devtools | Middleware missing or store instantiated multiple times |
| Updates work only after a full page reload | Persist middleware not awaited before rendering |
Ordered verification steps
- Confirm store creation includes needed middleware (e.g.,
immer,devtools). Look forcreatecalls that wrapimmerordevtools. - Inspect each
setusage: are you mutating the state directly (state.foo = 1) or returning a new object? - Check component subscription: is the selector passed to
useStorestable (e.g., a memoized function or plain property access) or is it created inline? - Review event handlers and
useEffectclosures for captured stale state values. - Open Zustand devtools (if enabled) and verify that the action appears in the log after the set call.
Fixes aligned with causes
1. Missing or misconfigured middleware
Ensure the store is created once with the required middleware:
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
const useStore = create(
devtools(
immer((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}))
)
)
export default useStore
If you only need immutable updates, you can omit immer and update state by returning a new object.
2. Direct state mutation
Replace mutating code with an immutable update or enable immer:
// ❌ Direct mutation (breaks shallow equality)
useStore.getState().set({ count: useStore.getState().count + 1 })
// ✅ Immutable update via functional set
useStore.set((state) => ({ count: state.count + 1 }))
3. Stale closures in event handlers
Move the logic outside the closure or use a functional updater that receives the latest state:
// ❌ Stale capture of `count`
useEffect(() => {
const id = setInterval(() => {
// `count` here is the value from the render where the effect ran
useStore.set({ count: useStore.getState().count + 1 })
}, 1000)
return () => clearInterval(id)
}, [])
// ✅ Use functional set to always read current state
useEffect(() => {
const id = setInterval(() => {
useStore.set((state) => ({ count: state.count + 1 }))
}, 1000)
return () => clearInterval(id)
}, [])
4. Unstable selectors causing missed subscriptions
Pass a stable selector to useStore or use useShallow for shallow comparison of returned objects:
// ❌ Inline function creates new reference each render
const count = useStore(state => state.count)
// ✅ Stable selector (property access)
const count = useStore(state => state.count)
// ✅ Or use useShallow when returning an object
const { count, name } = useStore(useShallow(state => ({ count: state.count, name: state.name })))
5. Persist hydration timing
When using persist, wait for hydration before rendering the UI:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const useStore = create(
persist(
(set) => ({ theme: 'light', toggleTheme: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })) }),
{ name: 'theme-storage' }
)
)
function App() {
const [hydrated, setHydrated] = React.useState(false)
// Assuming persist provides a `hydrate` promise
React.useEffect(() => {
useStore.persist.hydrate().then(() => setHydrated(true))
}, [])
if (!hydrated) return null
const theme = useStore(state => state.theme)
return …
}
Escalation criteria
If the UI still does not update after completing the checks above:
- Verify that no other library is replacing the store instance (e.g., multiple copies of Zustand bundled).
- Check for interactions with React 18 concurrent features (
startTransition,useDeferredValue) that might batch or defer updates. - Create a minimal reproducible example isolating the store and the offending component.
- Open an issue on the Zustand repository with the reproduction steps and the devtools log.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.