Skip to content

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

FixDevs ·

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 reactivity model has specific rules:

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

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);
});

For related React state issues, see Fix: React useState Not Updating and Fix: Redux State 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