Skip to content

Fix: Zustand Not Working — Component Not Re-rendering, State Reset on Refresh, or Selector Causing Infinite Loop

FixDevs · (Updated: )

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 loop

Why 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 changeuseStore() 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.
  • persist requires correct storage configurationlocalStorage works in the browser but not in SSR. Without specifying storage, it defaults to localStorage, which is undefined in Node.js/SSR environments.
  • State updates are batched in React 18 — multiple set() calls inside a setTimeout or 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-applies

Still 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles