Skip to content

Zustand Reference: Create Store, Actions, Selectors, Persist & Testing

Zustand is a minimal, hook-based state manager. No providers, no reducers, no boilerplate. You create a store with create(), access slices with selectors, and mutate state inside actions. The main benefit over Context + useReducer: granular re-renders. Components only re-render when the specific slice they selected changes. The main design decision: don’t put everything in one store — create multiple small stores by feature.

1. Create & Use a Store

create(), useStore selector, actions inside the store, and TypeScript types
// npm install zustand

import { create } from "zustand";

// Define types:
interface CounterStore {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset:     () => void;
  incrementBy: (amount: number) => void;
}

// Create store (actions live inside the store — not in components):
const useCounterStore = create<CounterStore>()((set) => ({
  count: 0,
  increment:   () => set((state) => ({ count: state.count + 1 })),
  decrement:   () => set((state) => ({ count: state.count - 1 })),
  reset:       () => set({ count: 0 }),
  incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
}));

// Use in component — selector picks only what you need:
function Counter() {
  const count     = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);

  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  );
}

// Outside React (imperative access):
const state   = useCounterStore.getState();
useCounterStore.setState({ count: 10 });
const unsub   = useCounterStore.subscribe(console.log);
unsub();   // unsubscribe

2. Async Actions, get(), and Complex State

Async data fetching inside store, get() to read current state, nested state updates
interface UserStore {
  users:    User[];
  loading:  boolean;
  error:    string | null;
  fetchUsers: () => Promise<void>;
  addUser:    (user: User) => void;
  removeUser: (id: string) => void;
}

const useUserStore = create<UserStore>()((set, get) => ({
  users:   [],
  loading: false,
  error:   null,

  fetchUsers: async () => {
    set({ loading: true, error: null });
    try {
      const res = await fetch("/api/users");
      if (!res.ok) throw new Error("Failed to fetch");
      const users = await res.json();
      set({ users, loading: false });
    } catch (e) {
      set({ error: (e as Error).message, loading: false });
    }
  },

  addUser: (user) => set((state) => ({
    users: [...state.users, user],
  })),

  removeUser: (id) => set((state) => ({
    users: state.users.filter((u) => u.id !== id),
  })),
}));

// get() reads current state without subscribing:
const useUserStore = create<UserStore>()((set, get) => ({
  // ...
  getActiveUsers: () => get().users.filter((u) => u.active),
}));

3. Selectors, Shallow Equality & Subscriptions

Granular re-renders with selectors, shallow() for object slices, subscribe for side effects
import { useShallow } from "zustand/react/shallow";

// Problem: this re-renders on ANY store change:
const store = useUserStore();   // entire store object, always new reference

// Solution: select only what you need:
const users   = useUserStore((s) => s.users);    // re-renders only when users change
const loading = useUserStore((s) => s.loading);  // re-renders only when loading changes

// Multiple values: use useShallow to prevent re-renders when values haven't changed:
const { users, loading } = useUserStore(
  useShallow((s) => ({ users: s.users, loading: s.loading }))
);
// Without useShallow: { users, loading } is a new object every render → always re-renders

// Select derived values (computed inside selector):
const activeCount = useUserStore((s) => s.users.filter((u) => u.active).length);

// Subscribe to changes outside React (for side effects):
const unsub = useUserStore.subscribe(
  (state)  => state.users,     // selector
  (users)  => console.log("Users changed:", users),  // listener
  { equalityFn: (a, b) => a.length === b.length }    // optional custom equality
);
// Call unsub() to stop listening

4. Persist & DevTools Middleware

Persist state to localStorage, immer for nested updates, and Redux DevTools
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { devtools } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";

// Persist to localStorage:
const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: "light",
      language: "en",
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: "settings-storage",          // localStorage key
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({ theme: state.theme }),  // only persist theme, not functions
    }
  )
);

// Redux DevTools (browser extension):
const useStore = create<Store>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set((s) => ({ count: s.count + 1 }), false, "increment"),
      //                                                      ^^^^^ replace?  ^^^ action name
    }),
    { name: "MyStore" }   // shows in DevTools tab
  )
);

// Immer for nested state mutations:
const useStore = create<Store>()(
  immer((set) => ({
    user: { profile: { name: "Alice", settings: { darkMode: false } } },
    toggleDarkMode: () => set((state) => {
      state.user.profile.settings.darkMode = !state.user.profile.settings.darkMode;
      // ↑ mutate directly — immer handles immutability
    }),
  }))
);

5. Slices Pattern & Testing

Split large stores into slices, combine them, and reset store state in tests
// Slices pattern — split by feature, compose into one store:
type CounterSlice = { count: number; increment: () => void };
type UserSlice    = { user: User | null; setUser: (u: User) => void };
type AppStore     = CounterSlice & UserSlice;

const createCounterSlice = (set: any): CounterSlice => ({
  count: 0,
  increment: () => set((s: AppStore) => ({ count: s.count + 1 })),
});

const createUserSlice = (set: any): UserSlice => ({
  user: null,
  setUser: (user) => set({ user }),
});

const useAppStore = create<AppStore>()((...a) => ({
  ...createCounterSlice(...a),
  ...createUserSlice(...a),
}));

// Testing — reset store between tests:
const initialState = useCounterStore.getInitialState?.() ?? { count: 0 };
beforeEach(() => useCounterStore.setState(initialState));

// Or reset in beforeEach with explicit initial values:
beforeEach(() => {
  useUserStore.setState({ users: [], loading: false, error: null });
});

// Test an action:
it("adds a user", () => {
  const { addUser, users } = useUserStore.getState();
  addUser({ id: "1", name: "Alice" });
  expect(useUserStore.getState().users).toHaveLength(1);
});

Track Node.js and React ecosystem releases at ReleaseRun. Related: React Reference | TanStack Query Reference | Next.js Reference | React EOL Tracker

🔍 Free tool: npm Package Health Checker — check Zustand and React state packages for known CVEs and active maintenance.

Founded

2023 in London, UK

Contact

hello@releaserun.com