Fix: Zustand Not Working — Component Not Re-rendering, State Reset on Refresh, or Selector Causing Infinite Loop
Part of: React & Frontend Errors
Quick Answer
How to fix Zustand state management issues — selector optimization, persist middleware, shallow comparison, devtools setup, slice pattern for large stores, and common subscription mistakes.
The Problem
A component doesn’t re-render when Zustand state changes:
const useStore = create((set) => ({
users: [],
setUsers: (users) => set({ users }),
}));
function UserList() {
const store = useStore(); // Subscribes to the ENTIRE store
return <ul>{store.users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
// Re-renders on ANY state change, not just users
}Or persist middleware doesn’t restore state on page refresh:
const useStore = create(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}),
{ name: 'app-store' }
)
);
// After refresh: theme is always 'light' (not persisted)Or a selector causes infinite re-renders:
// Selecting a new object/array reference every render
const items = useStore(state => state.items.filter(i => i.active));
// New array reference on every render → infinite re-render loopWhy This Happens
Zustand’s whole reactivity model fits in one sentence: a selector is a function that produces a value, and the component re-renders whenever that value changes by Object.is comparison. Nothing magical happens. There is no proxy intercepting property reads, no deep-equal comparison, no automatic dependency tracking. If your selector returns state.users.filter(u => u.active), that filter call produces a new array every time the selector runs — and selectors run on every store change to check whether they produced a different result. A new array reference is not Object.is equal to the previous array reference, so the component re-renders, which runs the selector again, which produces another new array. That is the infinite re-render loop. The fix is useShallow, computing inside the store, or selecting primitives.
The persist middleware story is the second-most-common source of bugs. Persist serializes the store to a configured storage (default localStorage) on every state change and rehydrates on creation. But rehydration is asynchronous in SSR frameworks: on the server, the persist call resolves synchronously with no data (because there is no localStorage), and the rendered HTML reflects the initial state. On the client, the persist call rehydrates from localStorage, and the client renders with the persisted state. Those two renders disagree, and React throws a hydration mismatch warning. The fix is either to render a placeholder until hydration completes, or to use onRehydrateStorage to defer rendering.
The third pitfall is the shallow import. Zustand v3 exported shallow from zustand/shallow and you used it as the second argument to useStore. Zustand v4 added useShallow from zustand/react/shallow for the hook-based pattern. Copy-pasting v3 examples into a v4 project produces selectors that compile but use referential equality despite the shallow import, and you wonder why nothing changed.
- Whole-store subscription re-renders on every change —
useStore()without a selector subscribes to the entire store. Any state change triggers a re-render, even for unrelated fields. - Selector uses strict equality by default — Zustand compares the previous and next selector result with
Object.is. If your selector returns a new object or array reference each time (even if the values are the same), the component re-renders every time any state changes. persistrequires correct storage configuration —localStorageworks in the browser but not in SSR. Without specifyingstorage, it defaults tolocalStorage, which isundefinedin Node.js/SSR environments.- State updates are batched in React 18 — multiple
set()calls inside asetTimeoutor async function are batched in React 18. If you expect immediate sequential re-renders, the behavior may differ. - Immer middleware is opt-in — without it, spreading and mutating nested state is your job. With it, you mutate the draft directly. Mixing both patterns inside the same store breaks updates.
Diagnostic Timeline
The reflex when Zustand “isn’t updating” is to add a console.log inside set and call it a day. That confirms the store changed but not whether the component subscribed correctly. Walk it.
Minute 0 — print the selector result. Inside your component, log the value returned by useStore(state => ...) on every render. If the log fires on every store change but the value looks the same, your selector returns a new reference each time. If the log does not fire when state changes, the selector is reading a field that did not actually change — meaning the bug is in your set call, not in the subscription.
Minute 3 — confirm the set call. Open the store definition and check whether your action calls set({ users }) or set(state => ({ users: [...state.users, user] })). The first replaces; the second merges. If you wrote set({ users: state.users.push(user) }), you returned the length of the new array, not the new array — and users becomes a number. The Array.prototype.push antipattern accounts for a surprising number of mutation bugs.
Minute 6 — verify selector reference stability. Replace useStore(state => state.items.filter(i => i.active)) with useStore(useShallow(state => state.items.filter(i => i.active))). If the component now updates correctly, your previous selector was creating a new array each time and causing the infinite render loop. If nothing changes, look upstream — the items field itself may not be updating.
Minute 9 — check persist hydration. If persisted state is missing on refresh, open DevTools -> Application -> Local Storage and look for your store name. If the key exists with the data, persist saved correctly but rehydration is failing — usually because the version field bumped and migrate was not defined. If the key is missing, persist is writing to a different storage (or to nothing because storage returned undefined in SSR). Define storage: createJSONStorage(() => localStorage) explicitly.
Minute 13 — inspect middleware order. Wrapping is bottom-up: devtools(persist(immer(...))) applies immer first, then persist, then devtools. If devtools shows mutations but persist does not save them, the order is wrong — persist needs to be inside devtools but outside immer. The other common bug is forgetting that devtools is dev-only; in production it is a no-op and “actions” do not appear, which makes you think your store is broken.
Fix 1: Use Selectors to Subscribe to Specific State
Always select only the state your component needs:
import { create } from 'zustand';
interface AppStore {
users: User[];
theme: 'light' | 'dark';
count: number;
setUsers: (users: User[]) => void;
setTheme: (theme: 'light' | 'dark') => void;
increment: () => void;
}
const useStore = create<AppStore>((set) => ({
users: [],
theme: 'light',
count: 0,
setUsers: (users) => set({ users }),
setTheme: (theme) => set({ theme }),
increment: () => set((state) => ({ count: state.count + 1 })),
}));
// WRONG — subscribes to entire store
function UserList() {
const store = useStore(); // Re-renders on count/theme change too
return <ul>{store.users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
// CORRECT — subscribe only to what you need
function UserList() {
const users = useStore((state) => state.users); // Only re-renders when users changes
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
function ThemeToggle() {
const theme = useStore((state) => state.theme);
const setTheme = useStore((state) => state.setTheme);
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme === 'light' ? '🌙' : '☀️'}
</button>
);
}
// Selecting multiple fields — use shallow for object selectors
import { shallow } from 'zustand/shallow';
function Header() {
// Without shallow: re-renders if ANY state changes (new object reference)
// With shallow: re-renders only if theme or username changes
const { theme, username } = useStore(
(state) => ({ theme: state.theme, username: state.username }),
shallow
);
return <header className={theme}>{username}</header>;
}Fix 2: Fix Derived State Causing Infinite Re-renders
When selectors return new references, use useShallow or memoize:
import { useShallow } from 'zustand/react/shallow';
// PROBLEM — new array reference every render
function ActiveItems() {
// Returns a new array on every call → always "different" → re-renders forever
const activeItems = useStore(state => state.items.filter(i => i.active));
return <ItemList items={activeItems} />;
}
// FIX 1 — useShallow for arrays/objects
function ActiveItems() {
const activeItems = useStore(
useShallow(state => state.items.filter(i => i.active))
);
// Shallow comparison: re-renders only if the array contents change
return <ItemList items={activeItems} />;
}
// FIX 2 — compute derived state inside the store
const useStore = create<Store>((set, get) => ({
items: [],
// Derived state as a getter
get activeItems() {
return get().items.filter(i => i.active);
},
}));
// FIX 3 — memoize with useMemo outside the selector
function ActiveItems() {
const items = useStore(state => state.items);
const activeItems = useMemo(() => items.filter(i => i.active), [items]);
return <ItemList items={activeItems} />;
}Fix 3: Configure persist Middleware
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
// Basic persist — defaults to localStorage
const useSettingsStore = create(
persist(
(set) => ({
theme: 'light' as 'light' | 'dark',
language: 'en',
setTheme: (theme: 'light' | 'dark') => set({ theme }),
setLanguage: (language: string) => set({ language }),
}),
{
name: 'settings-store', // localStorage key
// Persist only specific fields (not actions)
partialize: (state) => ({
theme: state.theme,
language: state.language,
// Exclude: setTheme, setLanguage (functions don't serialize)
}),
}
)
);
// SSR-safe persist (Next.js, Remix)
const useStore = create(
persist(
(set) => ({ count: 0, increment: () => set(s => ({ count: s.count + 1 })) }),
{
name: 'app-store',
storage: createJSONStorage(() => {
// Return localStorage safely — undefined during SSR
if (typeof window !== 'undefined') return localStorage;
return {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
};
}),
}
)
);
// sessionStorage instead of localStorage
const useSessionStore = create(
persist(
(set) => ({ token: null }),
{
name: 'session',
storage: createJSONStorage(() => sessionStorage),
}
)
);
// Custom storage (IndexedDB, AsyncStorage for React Native)
import { del, get, set as idbSet } from 'idb-keyval';
const idbStorage = {
getItem: async (name: string) => (await get(name)) ?? null,
setItem: async (name: string, value: string) => idbSet(name, value),
removeItem: async (name: string) => del(name),
};
const useIdbStore = create(
persist(
(set) => ({ largeData: [] }),
{
name: 'large-store',
storage: createJSONStorage(() => idbStorage),
}
)
);Handle persist hydration in SSR:
// Next.js — wait for hydration before rendering persisted state
function ThemeProvider({ children }) {
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
setHydrated(true);
}, []);
if (!hydrated) {
// Render with default state to match SSR
return <div className="light">{children}</div>;
}
return <ThemedContent>{children}</ThemedContent>;
}
// Or use the built-in onRehydrateStorage callback
const useStore = create(
persist(
(set) => ({ theme: 'light', _hydrated: false }),
{
name: 'app-store',
onRehydrateStorage: () => (state) => {
state?._setHydrated(true);
},
}
)
);Fix 4: Slice Pattern for Large Stores
Split large stores into slices for better organization:
import { create, StateCreator } from 'zustand';
// Define each slice
interface UserSlice {
users: User[];
currentUser: User | null;
setUsers: (users: User[]) => void;
setCurrentUser: (user: User | null) => void;
}
interface CartSlice {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
}
interface UISlice {
isLoading: boolean;
modal: string | null;
setLoading: (loading: boolean) => void;
openModal: (name: string) => void;
closeModal: () => void;
}
// Create slice factories
const createUserSlice: StateCreator<
UserSlice & CartSlice & UISlice,
[],
[],
UserSlice
> = (set) => ({
users: [],
currentUser: null,
setUsers: (users) => set({ users }),
setCurrentUser: (currentUser) => set({ currentUser }),
});
const createCartSlice: StateCreator<
UserSlice & CartSlice & UISlice,
[],
[],
CartSlice
> = (set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id),
})),
clearCart: () => set({ items: [] }),
});
const createUISlice: StateCreator<
UserSlice & CartSlice & UISlice,
[],
[],
UISlice
> = (set) => ({
isLoading: false,
modal: null,
setLoading: (isLoading) => set({ isLoading }),
openModal: (modal) => set({ modal }),
closeModal: () => set({ modal: null }),
});
// Combine slices into one store
export const useStore = create<UserSlice & CartSlice & UISlice>()((...a) => ({
...createUserSlice(...a),
...createCartSlice(...a),
...createUISlice(...a),
}));
// Create dedicated hooks per slice
export const useUserStore = () => useStore((state) => ({
users: state.users,
currentUser: state.currentUser,
setUsers: state.setUsers,
}));
export const useCartStore = () => useStore(
useShallow(state => ({
items: state.items,
addItem: state.addItem,
removeItem: state.removeItem,
clearCart: state.clearCart,
}))
);Fix 5: Devtools and Middleware
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
// Combine multiple middleware — order matters
const useStore = create<Store>()(
devtools(
persist(
immer((set) => ({
users: [],
// With immer — mutate draft state directly
addUser: (user: User) => set((state) => {
state.users.push(user); // Immer handles immutability
}),
updateUser: (id: string, updates: Partial<User>) => set((state) => {
const user = state.users.find(u => u.id === id);
if (user) Object.assign(user, updates);
}),
removeUser: (id: string) => set((state) => {
state.users = state.users.filter(u => u.id !== id);
}),
})),
{ name: 'app-store' }
),
{ name: 'AppStore' } // DevTools display name
)
);
// Action names in DevTools — name your set calls
const useStore = create(
devtools((set) => ({
count: 0,
increment: () => set(
(state) => ({ count: state.count + 1 }),
false, // Replace (false) or merge (false = merge, default)
'increment' // Action name shown in Redux DevTools
),
reset: () => set({ count: 0 }, false, 'reset'),
}))
);Fix 6: Subscribe Outside of React
Access and subscribe to Zustand state outside of React components:
// Get current state without subscribing (no re-renders)
const currentUser = useStore.getState().currentUser;
// Set state outside React
useStore.setState({ theme: 'dark' });
useStore.setState(state => ({ count: state.count + 1 }));
// Subscribe to state changes outside React (e.g., in a service)
const unsubscribe = useStore.subscribe(
(state) => state.currentUser, // Selector
(currentUser, previousUser) => {
console.log('User changed:', currentUser);
// Update analytics, sync to server, etc.
}
);
// Later: stop listening
unsubscribe();
// Temporal middleware — track history for undo/redo
import { temporal } from 'zundo';
const useStore = create(
temporal((set) => ({
items: [] as string[],
addItem: (item: string) => set(state => ({ items: [...state.items, item] })),
removeItem: (index: number) => set(state => ({
items: state.items.filter((_, i) => i !== index),
})),
}))
);
// Undo/redo
const { undo, redo, clear } = useStore.temporal.getState();
undo(); // Reverts last change
redo(); // Re-appliesStill Not Working?
State updates in async callbacks don’t trigger re-renders — Zustand updates are synchronous and immediate. If you update state inside a Promise or setTimeout, React 18 batches the re-renders. If your component still doesn’t update, verify the selector is actually selecting the right state and that the component is mounted and not unmounted by the time the update fires.
zustand/shallow vs useShallow — in Zustand v4+, import useShallow from zustand/react/shallow (not shallow from zustand/shallow). Using shallow directly as the second argument to useStore is the v3 API:
// v3 (old)
import { shallow } from 'zustand/shallow';
const { a, b } = useStore(state => ({ a: state.a, b: state.b }), shallow);
// v4 (current)
import { useShallow } from 'zustand/react/shallow';
const { a, b } = useStore(useShallow(state => ({ a: state.a, b: state.b })));Store resets between test runs — Zustand stores are module-level singletons. State persists between tests unless you reset it. Call useStore.setState(initialState, true) (the true replaces rather than merges) in a beforeEach to reset:
beforeEach(() => {
useStore.setState({ users: [], theme: 'light', count: 0 }, true);
});Persist version migration loses state — bumping version without supplying migrate causes persist to discard the saved data. Add a migrate(persistedState, version) callback that returns the transformed state, even if it just passes through. Without it, every version bump effectively logs your users out of their preferences.
Immer breaks Map and Set — Immer treats Map and Set as immutable by default. Mutating them through state.myMap.set(key, value) does nothing. Either enable Immer’s enableMapSet() once at app startup, or replace the structure with plain objects and arrays. The bug is silent — no error, just stale data.
SSR hydration mismatch on persisted state — server renders with default state because there is no localStorage; client rehydrates with persisted state on mount. React warns about the mismatch and may discard the persisted render. Use a useEffect to set a hydrated flag, render a placeholder until it is true, or use Next.js’s 'use client' boundary so the persisted store only runs on the client.
For related React state issues, see Fix: React useState Not Updating, Fix: Redux State Not Updating, Fix: Jotai Not Working, and Fix: React Context Not Updating.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Jotai Not Working — Atom Not Updating, Derived Atom Out of Sync, or atomWithStorage Hydration Error
How to fix Jotai state management issues — atom scope, derived atoms, async atoms with Suspense, atomWithStorage SSR, useAtomValue vs useSetAtom, and debugging stale state.
Fix: TanStack Query Not Working — Data Not Fetching, Cache Not Updating, or Mutation Not Triggering Re-render
How to fix TanStack Query (React Query v5) issues — query keys, stale time, enabled flag, mutation callbacks, optimistic updates, QueryClient setup, and SSR with prefetchQuery.
Fix: Valtio Not Working — Component Not Re-rendering, Snapshot Stale, or Proxy Mutation Not Tracked
How to fix Valtio state management issues — proxy vs snapshot, useSnapshot for React, subscribe for side effects, derived state with computed, async actions, and Valtio with React Server Components.
Fix: AutoAnimate Not Working — Transitions Not Playing, List Items Not Animating, or React State Changes Ignored
How to fix @formkit/auto-animate issues — parent ref setup, React useAutoAnimate hook, Vue directive, animation customization, disabling for specific elements, and framework integration.