Fix: Zustand Not Working — Component Not Re-rendering, State Reset on Refresh, or Selector Causing Infinite Loop
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 reactivity model has specific rules:
- 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.
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);
});For related React state issues, see Fix: React useState Not Updating and Fix: Redux State 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.