Introduction
Zustand has emerged as the most compelling alternative to Redux for React state management, offering a dramatically simpler API while delivering equal or better performance. Created by Poimandres (the collective behind react-spring, react-three-fiber, and jotai), Zustand weighs in at just 1.1 KB gzipped — compared to Redux Toolkit's 11 KB — and requires no providers, no boilerplate actions, no reducers, and no context wrappers. You create a store, use it in components, and update it directly. That's it.
The library's name means "state" in German, and its design philosophy reflects a minimalist approach: strip away everything unnecessary until only the essential state management primitives remain. Despite its tiny footprint, Zustand handles complex real-world scenarios including middleware composition, async actions, computed values, subscription-based re-renders, and full TypeScript type inference. Major companies like IBM, React Hook Form, and Poimandres' own projects use Zustand in production.
This guide covers everything from basic store creation to advanced patterns like state machines, temporal middleware for undo/redo, and cross-tab synchronization. Whether you're migrating from Redux, starting a new project, or evaluating state management options, this comprehensive walkthrough will give you the knowledge to leverage Zustand effectively in production applications.
Understanding Zustand: Core Concepts
Why Zustand Over Redux
Redux revolutionized state management by introducing predictable state containers, immutable updates, and time-travel debugging. However, its ceremony-heavy API — actions, action creators, reducers, thunks, selectors — creates significant boilerplate for even simple state changes. Redux Toolkit reduced this boilerplate substantially, but the fundamental architecture still requires wrapping your app in a Provider, dispatching actions through a reducer pipeline, and memoizing selectors to prevent unnecessary re-renders.
Zustand eliminates all of this ceremony. There is no Provider component to wrap your app. State updates happen by calling functions directly — no action types, no dispatching, no reducer switch statements. Components subscribe to specific slices of state and only re-render when those slices change, without requiring manual selector memoization. The result is less code, fewer bugs, and faster development cycles.
The Store Creation Model
Zustand stores are created with the create function, which takes a "state creator" function that defines both the state and the actions to modify it. This co-location of state and actions is a deliberate design choice — it makes stores self-contained and easy to reason about.
import { create } from "zustand";
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));The set function is Zustand's core primitive for state updates. It accepts either a partial state object (for merging) or a function that receives the current state and returns a partial state. Zustand handles immutability internally using structural sharing — it shallow-merges the update with the existing state, preserving references to unchanged properties.
Subscription and Re-render Behavior
One of Zustand's most important performance characteristics is its selective subscription model. When a component calls useCounterStore(selector), Zustand tracks which state properties the selector accesses and only triggers a re-render when those specific properties change. This eliminates the "all components re-render on any state change" problem that plagues naive Context API implementations.
// This component ONLY re-renders when 'count' changes
function DisplayCounter() {
const count = useCounterStore((state) => state.count);
return <span>{count}</span>;
}
// This component ONLY re-renders when actions change (never, in practice)
function CounterButtons() {
const increment = useCounterStore((state) => state.increment);
const decrement = useCounterStore((state) => state.decrement);
return (
<div>
<button onClick={decrement}>-</button>
<button onClick={increment}>+</button>
</div>
);
}The useShallow hook from Zustand handles cases where you need to select multiple values without triggering re-renders due to reference inequality:
import { useShallow } from "zustand/react/shallow";
function UserProfile() {
const { name, email, avatar } = useUserStore(
useShallow((state) => ({
name: state.name,
email: state.email,
avatar: state.avatar,
}))
);
// Only re-renders when name, email, or avatar actually change
return <div>{name} - {email}</div>;
}Server State and Async Patterns
Zustand handles async operations naturally because actions are just functions — you can make them async without any special middleware:
interface UserState {
user: User | null;
loading: boolean;
error: string | null;
fetchUser: (id: string) => Promise<void>;
updateUser: (id: string, data: Partial<User>) => Promise<void>;
}
const useUserStore = create<UserState>((set) => ({
user: null,
loading: false,
error: null,
fetchUser: async (id: string) => {
set({ loading: true, error: null });
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error("Failed to fetch user");
const user = await response.json();
set({ user, loading: false });
} catch (error) {
set({ error: (error as Error).message, loading: false });
}
},
updateUser: async (id: string, data: Partial<User>) => {
set({ loading: true, error: null });
try {
const response = await fetch(`/api/users/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error("Failed to update user");
const user = await response.json();
set({ user, loading: false });
} catch (error) {
set({ error: (error as Error).message, loading: false });
}
},
}));Architecture and Design Patterns
Store Slicing Pattern
For large applications, a monolithic store becomes unwieldy. Zustand's slicing pattern breaks a store into logical slices that are composed together:
// src/stores/slices/auth-slice.ts
interface AuthSlice {
token: string | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const createAuthSlice: StateCreator<AuthSlice> = (set) => ({
token: null,
isAuthenticated: false,
login: async (email, password) => {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const { token } = await response.json();
set({ token, isAuthenticated: true });
},
logout: () => set({ token: null, isAuthenticated: false }),
});
// src/stores/slices/cart-slice.ts
interface CartSlice {
items: CartItem[];
addItem: (product: Product) => void;
removeItem: (productId: string) => void;
clearCart: () => void;
total: () => number;
}
const createCartSlice: StateCreator<CartSlice> = (set, get) => ({
items: [],
addItem: (product) =>
set((state) => {
const existing = state.items.find((i) => i.productId === product.id);
if (existing) {
return {
items: state.items.map((i) =>
i.productId === product.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return { items: [...state.items, { productId: product.id, quantity: 1, product }] };
}),
removeItem: (productId) =>
set((state) => ({
items: state.items.filter((i) => i.productId !== productId),
})),
clearCart: () => set({ items: [] }),
total: () =>
get().items.reduce((sum, item) => sum + item.product.price * item.quantity, 0),
});
// src/stores/slices/ui-slice.ts
interface UISlice {
sidebarOpen: boolean;
theme: "light" | "dark";
toggleSidebar: () => void;
setTheme: (theme: "light" | "dark") => void;
}
const createUISlice: StateCreator<UISlice> = (set) => ({
sidebarOpen: false,
theme: "light",
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setTheme: (theme) => set({ theme }),
});
// src/stores/use-app-store.ts
import { create } from "zustand";
type AppStore = AuthSlice & CartSlice & UISlice;
const useAppStore = create<AppStore>()((...args) => ({
...createAuthSlice(...args),
...createCartSlice(...args),
...createUISlice(...args),
}));Middleware Architecture
Zustand's middleware system wraps the set and get functions to add capabilities like persistence, logging, and devtools integration:
import { create } from "zustand";
import { persist, devtools, subscribeWithSelector } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
interface AppState {
user: User | null;
preferences: UserPreferences;
setUser: (user: User) => void;
updatePreference: (key: keyof UserPreferences, value: unknown) => void;
}
// Middleware stack: immer -> devtools -> persist -> subscribeWithSelector
const useAppStore = create<AppState>()(
immer(
devtools(
persist(
subscribeWithSelector((set) => ({
user: null,
preferences: defaultPreferences,
setUser: (user) =>
set((state) => {
state.user = user; // Immer allows mutable syntax
}),
updatePreference: (key, value) =>
set((state) => {
state.preferences[key] = value;
}),
})),
{
name: "app-storage",
partialize: (state) => ({
preferences: state.preferences, // Only persist preferences, not user
}),
}
),
{ name: "AppStore" }
)
)
);Selector Composition Pattern
Create reusable, composable selectors that can be shared across components:
// src/stores/selectors.ts
import { useShallow } from "zustand/react/shallow";
// Primitive selectors
const selectUser = (state: AppStore) => state.user;
const selectIsAuthenticated = (state: AppStore) => state.isAuthenticated;
const selectCartItems = (state: AppStore) => state.items;
// Computed selectors (using get() for derived state)
const selectCartTotal = (state: AppStore) =>
state.items.reduce((sum, item) => sum + item.product.price * item.quantity, 0);
const selectCartItemCount = (state: AppStore) =>
state.items.reduce((sum, item) => sum + item.quantity, 0);
// Composite selectors with useShallow
const useAuthStatus = () =>
useAppStore(
useShallow((state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
logout: state.logout,
}))
);
const useCartSummary = () =>
useAppStore(
useShallow((state) => ({
itemCount: selectCartItemCount(state),
total: selectCartTotal(state),
clearCart: state.clearCart,
}))
);Step-by-Step Implementation
Let's build a complete task management application that demonstrates Zustand's key features — TypeScript integration, middleware, async actions, and optimized re-rendering.
Project Setup
npx create-react-app task-manager --template typescript
cd task-manager
npm install zustand immer
npm install -D @types/uuidDefining the Store with TypeScript
// src/stores/task-store.ts
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
export type Priority = "low" | "medium" | "high";
export type Status = "todo" | "in-progress" | "done";
export interface Task {
id: string;
title: string;
description: string;
priority: Priority;
status: Status;
assignee: string | null;
createdAt: string;
updatedAt: string;
tags: string[];
}
interface TaskState {
tasks: Task[];
filter: { status: Status | "all"; priority: Priority | "all" };
searchQuery: string;
// Actions
addTask: (task: Omit<Task, "id" | "createdAt" | "updatedAt">) => void;
updateTask: (id: string, updates: Partial<Task>) => void;
deleteTask: (id: string) => void;
moveTask: (id: string, status: Status) => void;
setFilter: (filter: Partial<TaskState["filter"]>) => void;
setSearchQuery: (query: string) => void;
// Async actions
fetchTasks: () => Promise<void>;
syncTasks: () => Promise<void>;
// Computed (accessed via get())
getFilteredTasks: () => Task[];
getTaskById: (id: string) => Task | undefined;
getTasksByStatus: (status: Status) => Task[];
}
export const useTaskStore = create<TaskState>()(
immer(
devtools(
persist(
(set, get) => ({
tasks: [],
filter: { status: "all", priority: "all" },
searchQuery: "",
addTask: (taskData) =>
set((state) => {
const now = new Date().toISOString();
state.tasks.push({
...taskData,
id: crypto.randomUUID(),
createdAt: now,
updatedAt: now,
});
}),
updateTask: (id, updates) =>
set((state) => {
const index = state.tasks.findIndex((t) => t.id === id);
if (index !== -1) {
Object.assign(state.tasks[index], updates, {
updatedAt: new Date().toISOString(),
});
}
}),
deleteTask: (id) =>
set((state) => {
state.tasks = state.tasks.filter((t) => t.id !== id);
}),
moveTask: (id, status) =>
set((state) => {
const task = state.tasks.find((t) => t.id === id);
if (task) {
task.status = status;
task.updatedAt = new Date().toISOString();
}
}),
setFilter: (filter) =>
set((state) => {
Object.assign(state.filter, filter);
}),
setSearchQuery: (query) =>
set((state) => {
state.searchQuery = query;
}),
fetchTasks: async () => {
try {
const response = await fetch("/api/tasks");
if (!response.ok) throw new Error("Failed to fetch tasks");
const tasks = await response.json();
set((state) => {
state.tasks = tasks;
});
} catch (error) {
console.error("Failed to fetch tasks:", error);
}
},
syncTasks: async () => {
const { tasks } = get();
try {
await fetch("/api/tasks/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(tasks),
});
} catch (error) {
console.error("Failed to sync tasks:", error);
}
},
getFilteredTasks: () => {
const { tasks, filter, searchQuery } = get();
return tasks.filter((task) => {
if (filter.status !== "all" && task.status !== filter.status) return false;
if (filter.priority !== "all" && task.priority !== filter.priority) return false;
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
task.title.toLowerCase().includes(query) ||
task.description.toLowerCase().includes(query) ||
task.tags.some((tag) => tag.toLowerCase().includes(query))
);
}
return true;
});
},
getTaskById: (id) => get().tasks.find((t) => t.id === id),
getTasksByStatus: (status) =>
get().tasks.filter((t) => t.status === status),
}),
{
name: "task-storage",
partialize: (state) => ({
tasks: state.tasks,
filter: state.filter,
}),
}
),
{ name: "TaskStore" }
)
)
);Building React Components
// src/components/TaskBoard.tsx
import { useTaskStore, type Status } from "../stores/task-store";
const COLUMNS: { status: Status; label: string }[] = [
{ status: "todo", label: "To Do" },
{ status: "in-progress", label: "In Progress" },
{ status: "done", label: "Done" },
];
export function TaskBoard() {
return (
<div className="task-board">
{COLUMNS.map((column) => (
<TaskColumn key={column.status} status={column.status} label={column.label} />
))}
</div>
);
}
function TaskColumn({ status, label }: { status: Status; label: string }) {
const tasks = useTaskStore((state) => state.getTasksByStatus(status));
const moveTask = useTaskStore((state) => state.moveTask);
return (
<div className="task-column">
<h3>{label} ({tasks.length})</h3>
<div className="task-list">
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</div>
</div>
);
}
function TaskCard({ task }: { task: Task }) {
const deleteTask = useTaskStore((state) => state.deleteTask);
const moveTask = useTaskStore((state) => state.moveTask);
return (
<div className={`task-card priority-${task.priority}`}>
<h4>{task.title}</h4>
<p>{task.description}</p>
<div className="task-tags">
{task.tags.map((tag) => (
<span key={tag} className="tag">{tag}</span>
))}
</div>
<div className="task-actions">
{task.status !== "done" && (
<button
onClick={() => {
const next: Status = task.status === "todo" ? "in-progress" : "done";
moveTask(task.id, next);
}}
>
Move →
</button>
)}
<button onClick={() => deleteTask(task.id)}>Delete</button>
</div>
</div>
);
}// src/components/TaskFilters.tsx
import { useTaskStore } from "../stores/task-store";
export function TaskFilters() {
const filter = useTaskStore((state) => state.filter);
const searchQuery = useTaskStore((state) => state.searchQuery);
const setFilter = useTaskStore((state) => state.setFilter);
const setSearchQuery = useTaskStore((state) => state.setSearchQuery);
return (
<div className="task-filters">
<input
type="text"
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<select
value={filter.status}
onChange={(e) => setFilter({ status: e.target.value as any })}
>
<option value="all">All Status</option>
<option value="todo">To Do</option>
<option value="in-progress">In Progress</option>
<option value="done">Done</option>
</select>
<select
value={filter.priority}
onChange={(e) => setFilter({ priority: e.target.value as any })}
>
<option value="all">All Priority</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
);
}Real-World Use Cases
Use Case 1: E-Commerce Cart with Optimistic Updates
E-commerce applications require instant UI feedback when users add items to their cart, even before the server confirms the operation. Zustand's direct state manipulation makes optimistic updates trivial — update the local state immediately, then sync with the server in the background. If the server request fails, roll back to the previous state. This pattern provides instant feedback while maintaining data consistency. Combined with the persist middleware, the cart survives page refreshes and browser restarts, creating a seamless shopping experience.
Use Case 2: Real-Time Collaboration with WebSocket Sync
Collaborative applications like Figma or Google Docs need to synchronize state across multiple clients in real time. Zustand stores integrate naturally with WebSocket connections — incoming messages from the server update the store, and user actions are broadcast to other clients. The subscribe middleware enables listening to specific state changes and sending only diffs over the wire. Combined with Zustand's immer middleware for complex nested updates, this pattern handles the intricate state management requirements of collaborative editing without external state synchronization libraries.
Use Case 3: Theme and Preference Management
Global UI preferences like theme, language, and accessibility settings need to be available everywhere in the application and persisted across sessions. Zustand's persist middleware with localStorage or IndexedDB storage handles this elegantly. The store automatically hydrates from storage on app load and saves changes as they occur. Selective persistence with partialize ensures only preferences are stored, not transient application state. Migration support in the persist middleware handles schema evolution when preferences change between app versions.
Use Case 4: Form State Management for Complex Wizards
Multi-step form wizards with complex validation logic benefit from Zustand's flat state structure. Unlike form libraries that manage individual field states, Zustand manages the entire wizard state as a single unit — current step, all field values across all steps, validation errors, and submission status. This makes cross-step validation (ensuring step 1 fields are still valid when the user is on step 3) trivial. The immer middleware makes updating deeply nested form state intuitive, and the devtools middleware provides visibility into the form state at every step for debugging.
Best Practices for Production
-
Keep stores focused and small: Each store should manage a single domain — authentication, shopping cart, UI preferences. Avoid monolithic stores that manage everything. Use the slice pattern to compose related state into larger stores when needed, but maintain the logical separation.
-
Use selectors to prevent unnecessary re-renders: Always select the minimum state your component needs.
useStore(state => state.count)is better thanuseStore(state => state). For components needing multiple values, useuseShallowto prevent re-renders caused by reference inequality. -
Leverage immer for complex nested state: When your state has deeply nested objects, the immer middleware lets you write "mutative" code that Zustand converts to immutable updates internally. This dramatically simplifies updates to nested arrays and objects without manual spread operations.
-
Use persist middleware for client-side state: Automatically save and restore state from localStorage, sessionStorage, or IndexedDB. Use
partializeto select which parts of state should be persisted — don't persist transient state like loading flags or temporary error messages. -
Implement devtools for debugging: The devtools middleware connects Zustand stores to Redux DevTools, enabling time-travel debugging, action inspection, and state diffing. Enable it in development and disable it in production for optimal performance.
-
Test stores independently from components: Zustand stores are plain JavaScript functions — test them without rendering React components. Call actions directly and assert state changes. This makes tests fast and focused on business logic rather than UI rendering.
-
Handle server state appropriately: Zustand manages client-side state excellently, but for server state (data fetched from APIs), consider pairing it with React Query or SWR. Use Zustand for UI state, user preferences, and client-side derived state; use React Query for server data caching, pagination, and background refetching.
-
Type your stores completely: Zustand's TypeScript integration provides full type inference when you define your state interface. Always provide the type parameter to
create<T>()to get autocomplete, type checking onsetandget, and type-safe selectors in components.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Selecting entire state object | Every state change re-renders every consumer | Use granular selectors; select only the properties your component needs |
| Creating stores inside components | Store recreated on every render, losing state | Always create stores in module scope, outside any component |
| Mutating state without immer | Silent failures, stale renders | Use immer middleware or spread operators; never mutate the state object directly in set |
| Storing server data without caching | Redundant fetches, stale data | Use React Query/SWR for server state; Zustand for client state |
| Over-persisting state | Stale data on hydration, security leaks | Use partialize to persist only necessary state; exclude tokens and transient data |
| Missing dependency in selectors | Components don't re-render when they should | Ensure selector returns all values the component renders; use useShallow for objects |
Performance Optimization
Zustand is inherently performant due to its subscription model, but specific patterns can further optimize rendering:
// External store subscription for non-React contexts
// Useful for event handlers, WebSocket callbacks, and middleware
const unsubscribe = useTaskStore.subscribe(
(state, prevState) => {
// Only called when state actually changes
if (state.tasks.length !== prevState.tasks.length) {
analytics.track("task_count_changed", { count: state.tasks.length });
}
}
);
// Cleanup on app unmount
useEffect(() => unsubscribe, []);
// Transient updates with zustand for high-frequency state
// Use for values that change rapidly (mouse position, scroll, animations)
function useMousePosition() {
const position = useAppStore((state) => state.mousePosition);
useEffect(() => {
const handler = (e: MouseEvent) => {
// Use `setState` directly without triggering React renders
useAppStore.setState({ mousePosition: { x: e.clientX, y: e.clientY } });
};
window.addEventListener("mousemove", handler);
return () => window.removeEventListener("mousemove", handler);
}, []);
return position;
}Benchmark comparisons show that Zustand outperforms Redux and Context API in scenarios with frequent updates and many subscribed components. The key advantage is that Zustand's subscription system bypasses React's reconciler for unchanged values — if the selected state is referentially equal to the previous value, no re-render occurs at all.
Comparison with Alternatives
| Feature | Zustand | Redux Toolkit | Jotai | Recoil | Context API |
|---|---|---|---|---|---|
| Bundle Size | 1.1 KB | 11 KB | 2.4 KB | 15 KB | 0 KB (built-in) |
| Boilerplate | Minimal | Moderate | Minimal | Low | Moderate |
| TypeScript Support | Excellent | Good | Excellent | Good | Manual |
| DevTools | Via middleware | Built-in | Limited | Limited | None |
| Persistence | Middleware | Manual | Middleware | Manual | Manual |
| Async Actions | Native | Thunks/Saga | Native | Selectors | Manual |
| Provider Required | No | Yes | Yes | Yes | Yes |
| Re-render Optimization | Automatic | Manual selectors | Automatic | Automatic | Manual |
| Learning Curve | Very Low | Moderate | Low | Moderate | Low |
Zustand's strongest advantages are its zero-provider architecture, automatic re-render optimization, and minimal API surface. For applications that primarily need client-side state management (UI state, preferences, cross-component state), Zustand offers the best developer experience with the least code. For applications heavily dependent on server state, pairing Zustand with React Query provides the optimal combination.
Advanced Patterns and Techniques
State Machine Pattern
Implement finite state machines using Zustand for complex state transitions:
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: Data[] }
| { status: "error"; error: string };
interface MachineState {
fetchState: FetchState;
fetch: () => Promise<void>;
reset: () => void;
}
const useMachineStore = create<MachineState>((set) => ({
fetchState: { status: "idle" },
fetch: async () => {
set({ fetchState: { status: "loading" } });
try {
const response = await fetch("/api/data");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
set({ fetchState: { status: "success", data } });
} catch (error) {
set({ fetchState: { status: "error", error: (error as Error).message } });
}
},
reset: () => set({ fetchState: { status: "idle" } }),
}));
// Type-safe state matching in components
function DataDisplay() {
const fetchState = useMachineStore((state) => state.fetchState);
switch (fetchState.status) {
case "idle":
return <button onClick={useMachineStore.getState().fetch}>Load Data</button>;
case "loading":
return <Spinner />;
case "success":
return <DataTable data={fetchState.data} />;
case "error":
return <ErrorBanner message={fetchState.error} />;
}
}Cross-Tab Synchronization
Synchronize state across browser tabs using the BroadcastChannel API:
function createBroadcastMiddleware<T>(channelName: string) {
const channel = new BroadcastChannel(channelName);
return (config: StateCreator<T>) =>
(set: any, get: any, api: any) => {
// Listen for messages from other tabs
channel.onmessage = (event) => {
set(event.data, false); // false prevents broadcasting back
};
// Wrap set to broadcast changes to other tabs
const broadcastSet: typeof set = (partial: any, replace?: boolean) => {
set(partial, replace);
channel.postMessage(get());
};
return config(broadcastSet, get, api);
};
}
// Usage
const useSharedStore = create(
createBroadcastMiddleware<AppState>("app-sync")((set) => ({
// ... state and actions
}))
);Temporal Middleware (Undo/Redo)
interface TemporalState<T> {
past: T[];
future: T[];
undo: () => void;
redo: () => void;
clear: () => void;
}
function createTemporalMiddleware<T extends object>(maxHistory: number = 50) {
return (config: StateCreator<T & TemporalState<T>>) =>
(set: any, get: any, api: any) => {
const temporalActions: TemporalState<T> = {
past: [],
future: [],
undo: () => {
const { past, future } = get();
if (past.length === 0) return;
const previous = past[past.length - 1];
const current = { ...get() };
delete current.past;
delete current.future;
delete current.undo;
delete current.redo;
delete current.clear;
set({ ...previous, past: past.slice(0, -1), future: [current, ...future] });
},
redo: () => {
const { past, future } = get();
if (future.length === 0) return;
const next = future[0];
const current = { ...get() };
delete current.past;
delete current.future;
delete current.undo;
delete current.redo;
delete current.clear;
set({ ...next, past: [...past, current], future: future.slice(1) });
},
clear: () => set({ past: [], future: [] }),
};
return config(set, get, api);
};
}Testing Strategies
Zustand stores are straightforward to test because they are pure JavaScript without React dependencies:
// src/stores/__tests__/task-store.test.ts
import { useTaskStore } from "../task-store";
// Reset store between tests
beforeEach(() => {
useTaskStore.setState({
tasks: [],
filter: { status: "all", priority: "all" },
searchQuery: "",
});
});
describe("TaskStore", () => {
it("should add a task", () => {
useTaskStore.getState().addTask({
title: "Test Task",
description: "Test Description",
priority: "high",
status: "todo",
assignee: null,
tags: ["test"],
});
const { tasks } = useTaskStore.getState();
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toBe("Test Task");
expect(tasks[0].id).toBeDefined();
expect(tasks[0].createdAt).toBeDefined();
});
it("should move task to next status", () => {
useTaskStore.getState().addTask({
title: "Test",
description: "",
priority: "medium",
status: "todo",
assignee: null,
tags: [],
});
const taskId = useTaskStore.getState().tasks[0].id;
useTaskStore.getState().moveTask(taskId, "in-progress");
expect(useTaskStore.getState().tasks[0].status).toBe("in-progress");
});
it("should filter tasks by status", () => {
// Add multiple tasks with different statuses
const state = useTaskStore.getState();
state.addTask({ title: "Todo", description: "", priority: "low", status: "todo", assignee: null, tags: [] });
state.addTask({ title: "Done", description: "", priority: "low", status: "done", assignee: null, tags: [] });
useTaskStore.setState({ filter: { status: "todo", priority: "all" } });
const filtered = useTaskStore.getState().getFilteredTasks();
expect(filtered).toHaveLength(1);
expect(filtered[0].title).toBe("Todo");
});
it("should filter tasks by search query", () => {
const state = useTaskStore.getState();
state.addTask({ title: "Fix login bug", description: "", priority: "high", status: "todo", assignee: null, tags: ["auth"] });
state.addTask({ title: "Add dark mode", description: "", priority: "low", status: "todo", assignee: null, tags: ["ui"] });
useTaskStore.setState({ searchQuery: "login" });
const filtered = useTaskStore.getState().getFilteredTasks();
expect(filtered).toHaveLength(1);
expect(filtered[0].title).toBe("Fix login bug");
});
});For component integration tests, use renderHook from React Testing Library to test components that consume Zustand stores:
import { renderHook, act } from "@testing-library/react";
import { useTaskStore } from "../task-store";
describe("useTaskStore hook", () => {
it("should re-render only when selected state changes", () => {
let renderCount = 0;
const { result } = renderHook(() => {
renderCount++;
return useTaskStore((state) => state.tasks.length);
});
const initialRenderCount = renderCount;
// Changing filter should NOT cause re-render (tasks length unchanged)
act(() => {
useTaskStore.getState().setFilter({ status: "done" });
});
expect(renderCount).toBe(initialRenderCount);
// Adding task SHOULD cause re-render (tasks length changed)
act(() => {
useTaskStore.getState().addTask({
title: "New",
description: "",
priority: "low",
status: "todo",
assignee: null,
tags: [],
});
});
expect(renderCount).toBe(initialRenderCount + 1);
});
});Future Outlook
Zustand continues to evolve with the React ecosystem. Several developments are shaping its future direction. React Server Components integration is actively being explored, with patterns emerging for sharing state between server and client components. The challenge of serializing Zustand state for server rendering while maintaining type safety is being addressed through new APIs and middleware.
Concurrent features in React 18 and beyond work naturally with Zustand because the library uses useSyncExternalStore under the hood — the official React API for external stores that correctly handles concurrent rendering, tearing, and suspense. This means Zustand is future-proof against React's ongoing rendering architecture changes.
Ecosystem growth continues with community middleware for features like undo/redo, state machines, IndexedDB persistence, and cross-framework support. The middleware architecture makes it easy for the community to extend Zustand without modifying the core library. The emergence of frameworks like Next.js App Router and Remix is driving new patterns for server-client state coordination that Zustand is well-positioned to address.
Zustand v5 brought improved TypeScript inference, the useShallow hook as a first-class API, and cleaner middleware composition. Future versions will likely focus on tighter React 19 integration, improved devtools experience, and built-in patterns for common use cases like optimistic updates and real-time synchronization.
Conclusion
Zustand represents a philosophical shift in React state management — the realization that simpler is almost always better. By eliminating providers, reducers, action types, and selector boilerplate, Zustand lets developers focus on what matters: defining state, writing actions, and building UI. Its tiny bundle size, excellent TypeScript support, automatic re-render optimization, and composable middleware architecture make it suitable for everything from small widgets to large enterprise applications.
Key takeaways from this guide:
- Zustand requires no Provider — create a store and use it anywhere in your component tree without context wrappers, which eliminates a common source of bugs and simplifies testing.
- Selective subscriptions are automatic — components re-render only when their selected state slice changes, without manual memoization or selector optimization.
- The slice pattern scales to large applications — compose focused slices into larger stores while maintaining clean separation of concerns.
- Middleware is composable — stack immer, devtools, persist, and custom middleware to add capabilities without modifying store logic.
- Stores are testable without React — call actions and assert state changes directly, making tests fast and focused on business logic.
- Async actions are plain async functions — no thunks, sagas, or special syntax needed; just use async/await in your actions.
- Pair Zustand with React Query for server state — use Zustand for client state (UI, preferences, cross-component state) and React Query for server data caching and synchronization.
Start using Zustand today by creating a single focused store for your most common state management need. Once you experience the simplicity of direct state manipulation with automatic re-render optimization, you'll understand why Zustand has become the preferred state management library for a growing number of React developers and teams.