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