React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

zustandasyncloadingerror-states

Async Actions

Recipe

Define async functions as store actions. Use set to manage loading and error states alongside the async operation. No special middleware is needed.

import { create } from "zustand";
 
interface User {
  id: string;
  name: string;
  email: string;
}
 
interface UserStore {
  users: User[];
  isLoading: boolean;
  error: string | null;
  fetchUsers: () => Promise<void>;
}
 
export const useUserStore = create<UserStore>((set) => ({
  users: [],
  isLoading: false,
  error: null,
 
  fetchUsers: async () => {
    set({ isLoading: true, error: null });
    try {
      const res = await fetch("/api/users");
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const users = await res.json();
      set({ users, isLoading: false });
    } catch (err) {
      set({ error: (err as Error).message, isLoading: false });
    }
  },
}));

Working Example

// stores/product-store.ts
import { create } from "zustand";
 
interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
}
 
interface ProductStore {
  products: Product[];
  selectedProduct: Product | null;
  isLoading: boolean;
  error: string | null;
 
  fetchProducts: () => Promise<void>;
  fetchProduct: (id: string) => Promise<void>;
  createProduct: (input: Omit<Product, "id">) => Promise<Product>;
  updateStock: (id: string, delta: number) => Promise<void>;
}
 
export const useProductStore = create<ProductStore>((set, get) => ({
  products: [],
  selectedProduct: null,
  isLoading: false,
  error: null,
 
  fetchProducts: async () => {
    set({ isLoading: true, error: null });
    try {
      const res = await fetch("/api/products");
      const products = await res.json();
      set({ products, isLoading: false });
    } catch (err) {
      set({ error: (err as Error).message, isLoading: false });
    }
  },
 
  fetchProduct: async (id) => {
    set({ isLoading: true, error: null });
    try {
      const res = await fetch(`/api/products/${id}`);
      const product = await res.json();
      set({ selectedProduct: product, isLoading: false });
    } catch (err) {
      set({ error: (err as Error).message, isLoading: false });
    }
  },
 
  createProduct: async (input) => {
    set({ isLoading: true, error: null });
    try {
      const res = await fetch("/api/products", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(input),
      });
      const newProduct = await res.json();
      set((state) => ({
        products: [...state.products, newProduct],
        isLoading: false,
      }));
      return newProduct;
    } catch (err) {
      set({ error: (err as Error).message, isLoading: false });
      throw err;
    }
  },
 
  updateStock: async (id, delta) => {
    // Optimistic update
    const previousProducts = get().products;
    set((state) => ({
      products: state.products.map((p) =>
        p.id === id ? { ...p, stock: p.stock + delta } : p
      ),
    }));
 
    try {
      await fetch(`/api/products/${id}/stock`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ delta }),
      });
    } catch (err) {
      // Rollback on failure
      set({ products: previousProducts, error: (err as Error).message });
    }
  },
}));
// components/product-list.tsx
"use client";
 
import { useEffect } from "react";
import { useProductStore } from "@/stores/product-store";
 
export function ProductList() {
  const products = useProductStore((s) => s.products);
  const isLoading = useProductStore((s) => s.isLoading);
  const error = useProductStore((s) => s.error);
  const fetchProducts = useProductStore((s) => s.fetchProducts);
 
  useEffect(() => {
    fetchProducts();
  }, [fetchProducts]);
 
  if (isLoading) return <div>Loading products...</div>;
  if (error) return <div>Error: {error}</div>;
 
  return (
    <div>
      {products.map((p) => (
        <div key={p.id}>
          <h3>{p.name}</h3>
          <p>${p.price} — {p.stock} in stock</p>
        </div>
      ))}
    </div>
  );
}

Deep Dive

How It Works

  • Zustand actions are plain functions. There is no distinction between sync and async actions at the store level.
  • set can be called multiple times in an async action: once to set loading, once to set data or error.
  • Each set call triggers a synchronous state update. React 18+ batches renders, so rapid sequential set calls may be batched.
  • Optimistic updates use get() to snapshot current state before the async operation, then roll back on failure.
  • Async actions can return values, letting the caller react to the result (e.g., navigate after creation).

Variations

Generic async action helper:

function asyncAction<T>(
  set: any,
  fn: () => Promise<T>,
  onSuccess: (data: T) => Partial<any>
) {
  set({ isLoading: true, error: null });
  return fn()
    .then((data) => {
      set({ ...onSuccess(data), isLoading: false });
      return data;
    })
    .catch((err) => {
      set({ error: (err as Error).message, isLoading: false });
      throw err;
    });
}
 
// Usage
const useStore = create((set) => ({
  items: [],
  isLoading: false,
  error: null,
  fetchItems: () =>
    asyncAction(set, () => fetch("/api/items").then((r) => r.json()), (items) => ({ items })),
}));

Per-action loading states:

interface Store {
  data: Record<string, unknown>;
  loading: Record<string, boolean>;
  setLoading: (key: string, value: boolean) => void;
}
 
const useStore = create<Store>((set) => ({
  data: {},
  loading: {},
  setLoading: (key, value) =>
    set((s) => ({ loading: { ...s.loading, [key]: value } })),
}));

Abort controller for cancellation:

const useStore = create((set) => {
  let controller: AbortController | null = null;
 
  return {
    data: null,
    fetchData: async () => {
      controller?.abort();
      controller = new AbortController();
 
      try {
        const res = await fetch("/api/data", { signal: controller.signal });
        set({ data: await res.json() });
      } catch (err) {
        if ((err as Error).name !== "AbortError") {
          set({ error: (err as Error).message });
        }
      }
    },
  };
});

TypeScript Notes

  • Async actions return Promise<void> or Promise<T> and should be typed accordingly.
  • Error narrowing with (err as Error).message is common since catch blocks receive unknown.
interface AsyncState {
  isLoading: boolean;
  error: string | null;
}
 
interface WithAsync<T> extends AsyncState {
  data: T | null;
  fetch: () => Promise<void>;
}

Gotchas

  • A single isLoading boolean shared across multiple async actions can conflict. If fetchProducts and fetchProduct both use the same isLoading, one can overwrite the other.
  • Race conditions occur when the same action is called multiple times rapidly. The last call wins, but intermediate set calls still fire. Use abort controllers or ignore stale responses.
  • set inside a try/catch with async code does not roll back automatically on error. You must handle rollback logic manually.
  • If an async action throws after the component unmounts, the set call still updates the store (no error, but the old component's effect cleanup is gone).
  • Zustand does not track pending promises. If you need to deduplicate inflight requests, implement that logic yourself.

Alternatives

ApproachProsCons
Inline async actionsSimple, no middlewareManual loading/error state management
SWR or React Query for fetchingAutomatic caching, dedup, retrySeparate from Zustand store
Redux Toolkit createAsyncThunkStructured lifecycle (pending, fulfilled, rejected)Redux ecosystem required
Custom async middlewareReusable across actionsAdded complexity

FAQs

Do you need special middleware to use async actions in Zustand?
  • No. Zustand actions are plain functions; there is no distinction between sync and async at the store level.
  • Just define an async function that calls set at different points (loading, success, error).
How do you manage loading and error states for an async action?
fetchUsers: async () => {
  set({ isLoading: true, error: null });
  try {
    const res = await fetch("/api/users");
    const users = await res.json();
    set({ users, isLoading: false });
  } catch (err) {
    set({ error: (err as Error).message, isLoading: false });
  }
},
How does the optimistic update pattern work in Zustand?
  • Use get() to snapshot the current state before the async operation.
  • Apply the optimistic change immediately with set.
  • If the async call fails, roll back by calling set with the snapshot.
Can an async action return a value to the caller?
  • Yes. Async actions can return Promise<T>.
  • The calling component can await the result, e.g., to navigate after a successful create.
Gotcha: What happens when two async actions share a single isLoading boolean?
  • They conflict. If fetchProducts and fetchProduct both set isLoading, one can overwrite the other's loading state.
  • Use per-action loading keys (e.g., loading: Record<string, boolean>) to avoid this.
Gotcha: How do you handle race conditions with rapidly repeated async calls?
  • Use an AbortController to cancel the previous in-flight request.
  • Check AbortError in the catch block and ignore it.
controller?.abort();
controller = new AbortController();
const res = await fetch(url, { signal: controller.signal });
Does set inside a catch block automatically roll back previous set calls?
  • No. Zustand does not roll back automatically.
  • You must implement rollback logic manually using a state snapshot taken before the async operation.
What happens if set is called after the component that triggered the action unmounts?
  • The set call still updates the store -- Zustand stores live outside React.
  • There is no error, but the unmounted component's effect cleanup is gone.
How do you type an async action in TypeScript?
interface UserStore {
  users: User[];
  isLoading: boolean;
  error: string | null;
  fetchUsers: () => Promise<void>;
  createUser: (input: Omit<User, "id">) => Promise<User>;
}
  • Use Promise<void> for fire-and-forget actions and Promise<T> when returning data.
How do you type the error in a catch block with TypeScript?
  • catch blocks receive unknown in TypeScript.
  • Cast to Error explicitly: (err as Error).message.
  • Alternatively, use a type guard for safer narrowing.
Does Zustand deduplicate inflight requests automatically?
  • No. Zustand does not track pending promises.
  • If you need deduplication, implement it yourself (e.g., with a flag or AbortController).
How does a generic async action helper reduce boilerplate?
  • It wraps the set({ isLoading: true }) / try / catch / set({ isLoading: false }) pattern into a reusable function.
  • Each action only needs to provide the fetch logic and a success mapper.