Why Zustand?
Redux Toolkit is powerful but verbose. Jotai is atomic but unfamiliar. Zustand sits in the middle: a single function call creates a store, components subscribe with a selector, and re-renders only happen when the selected value changes. The entire library is under 2KB gzipped.
Basic Store
import { create } from "zustand";
interface TaskStore {
tasks: Task[];
isLoading: boolean;
fetchTasks: (projectId: string) => Promise<void>;
addTask: (task: Task) => void;
removeTask: (id: string) => void;
}
export const useTaskStore = create<TaskStore>()((set) => ({
tasks: [],
isLoading: false,
fetchTasks: async (projectId) => {
set({ isLoading: true });
const tasks = await api.tasks.list(projectId);
set({ tasks, isLoading: false });
},
addTask: (task) => set((state) => ({ tasks: [...state.tasks, task] })),
removeTask: (id) =>
set((state) => ({ tasks: state.tasks.filter((t) => t.id !== id) })),
}));
Using it in a component — only re-renders when tasks changes, not on isLoading changes:
function TaskList() {
const tasks = useTaskStore((state) => state.tasks);
const fetchTasks = useTaskStore((state) => state.fetchTasks);
useEffect(() => { fetchTasks(projectId); }, [projectId, fetchTasks]);
return <ul>{tasks.map(t => <li key={t.id}>{t.title}</li>)}</ul>;
}
Zustand v5 Breaking Changes
No more auto-wrapping in act(): In v4, React Testing Library's act() was applied automatically during tests. In v5, you manage this explicitly:
// v5 — explicit act() in tests
import { act } from "@testing-library/react";
await act(async () => {
useTaskStore.getState().addTask(newTask);
});
Stricter TypeScript: The create<T>() function now requires the state type argument. The old implicit inference sometimes produced incorrect types:
// v4 — sometimes worked incorrectly
const useStore = create((set) => ({ count: 0 }));
// v5 — explicit type required
const useStore = create<{ count: number }>()((set) => ({ count: 0 }));
Slices Pattern for Large Stores
For large apps, split the store into slices:
// stores/slices/taskSlice.ts
import type { StateCreator } from "zustand";
export interface TaskSlice {
tasks: Task[];
addTask: (task: Task) => void;
}
export const createTaskSlice: StateCreator<TaskSlice> = (set) => ({
tasks: [],
addTask: (task) => set((state) => ({ tasks: [...state.tasks, task] })),
});
// stores/slices/uiSlice.ts
export interface UISlice {
sidebarOpen: boolean;
toggleSidebar: () => void;
}
export const createUISlice: StateCreator<UISlice> = (set) => ({
sidebarOpen: true,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
});
// stores/root.ts
type RootStore = TaskSlice & UISlice;
export const useStore = create<RootStore>()((...a) => ({
...createTaskSlice(...a),
...createUISlice(...a),
}));
Persist Middleware
import { create } from "zustand";
import { persist } from "zustand/middleware";
const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
theme: "light",
setTheme: (theme) => set({ theme }),
}),
{
name: "app-settings",
// defaults to localStorage — use sessionStorage if needed:
storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({ theme: state.theme }), // only persist theme
}
)
);
Immer Middleware
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
const useStore = create<Store>()(
immer((set) => ({
users: [] as User[],
updateUser: (id: string, name: string) =>
set((state) => {
const user = state.users.find(u => u.id === id);
if (user) user.name = name; // mutate directly — Immer handles immutability
}),
}))
);
When to Choose What
| Scenario | Recommended | |----------|-------------| | Simple global state (1-3 stores) | Zustand | | Atomic state with many fine-grained pieces | Jotai | | Complex state machines / time-travel debugging | Redux Toolkit | | Server state (fetching/caching) | TanStack Query |
References: Zustand GitHub · docs · v5 migration