MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Zustand: Lightweight State Management for React

Replace Redux with Zustand: simple API, TypeScript support, middleware, and patterns.

ZustandReactState ManagementFrontend

By MinhVo

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.

Hero image representing state management concepts

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

Conceptual illustration of state management flow

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

Architecture diagram of Zustand store patterns

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/uuid

Defining 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

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

  2. Use selectors to prevent unnecessary re-renders: Always select the minimum state your component needs. useStore(state => state.count) is better than useStore(state => state). For components needing multiple values, use useShallow to prevent re-renders caused by reference inequality.

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

  4. Use persist middleware for client-side state: Automatically save and restore state from localStorage, sessionStorage, or IndexedDB. Use partialize to select which parts of state should be persisted — don't persist transient state like loading flags or temporary error messages.

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

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

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

  8. 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 on set and get, and type-safe selectors in components.

Common Pitfalls and Solutions

PitfallImpactSolution
Selecting entire state objectEvery state change re-renders every consumerUse granular selectors; select only the properties your component needs
Creating stores inside componentsStore recreated on every render, losing stateAlways create stores in module scope, outside any component
Mutating state without immerSilent failures, stale rendersUse immer middleware or spread operators; never mutate the state object directly in set
Storing server data without cachingRedundant fetches, stale dataUse React Query/SWR for server state; Zustand for client state
Over-persisting stateStale data on hydration, security leaksUse partialize to persist only necessary state; exclude tokens and transient data
Missing dependency in selectorsComponents don't re-render when they shouldEnsure 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

FeatureZustandRedux ToolkitJotaiRecoilContext API
Bundle Size1.1 KB11 KB2.4 KB15 KB0 KB (built-in)
BoilerplateMinimalModerateMinimalLowModerate
TypeScript SupportExcellentGoodExcellentGoodManual
DevToolsVia middlewareBuilt-inLimitedLimitedNone
PersistenceMiddlewareManualMiddlewareManualManual
Async ActionsNativeThunks/SagaNativeSelectorsManual
Provider RequiredNoYesYesYesYes
Re-render OptimizationAutomaticManual selectorsAutomaticAutomaticManual
Learning CurveVery LowModerateLowModerateLow

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:

  1. 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.
  2. Selective subscriptions are automatic — components re-render only when their selected state slice changes, without manual memoization or selector optimization.
  3. The slice pattern scales to large applications — compose focused slices into larger stores while maintaining clean separation of concerns.
  4. Middleware is composable — stack immer, devtools, persist, and custom middleware to add capabilities without modifying store logic.
  5. Stores are testable without React — call actions and assert state changes directly, making tests fast and focused on business logic.
  6. Async actions are plain async functions — no thunks, sagas, or special syntax needed; just use async/await in your actions.
  7. 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.