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.
setcan be called multiple times in an async action: once to set loading, once to set data or error.- Each
setcall triggers a synchronous state update. React 18+ batches renders, so rapid sequentialsetcalls 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>orPromise<T>and should be typed accordingly. - Error narrowing with
(err as Error).messageis common sincecatchblocks receiveunknown.
interface AsyncState {
isLoading: boolean;
error: string | null;
}
interface WithAsync<T> extends AsyncState {
data: T | null;
fetch: () => Promise<void>;
}Gotchas
- A single
isLoadingboolean shared across multiple async actions can conflict. IffetchProductsandfetchProductboth use the sameisLoading, one can overwrite the other. - Race conditions occur when the same action is called multiple times rapidly. The last call wins, but intermediate
setcalls still fire. Use abort controllers or ignore stale responses. setinside 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
setcall 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
| Approach | Pros | Cons |
|---|---|---|
| Inline async actions | Simple, no middleware | Manual loading/error state management |
| SWR or React Query for fetching | Automatic caching, dedup, retry | Separate from Zustand store |
| Redux Toolkit createAsyncThunk | Structured lifecycle (pending, fulfilled, rejected) | Redux ecosystem required |
| Custom async middleware | Reusable across actions | Added 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
asyncfunction that callssetat 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
setwith the snapshot.
Can an async action return a value to the caller?
- Yes. Async actions can return
Promise<T>. - The calling component can
awaitthe result, e.g., to navigate after a successful create.
Gotcha: What happens when two async actions share a single isLoading boolean?
- They conflict. If
fetchProductsandfetchProductboth setisLoading, 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
AbortControllerto cancel the previous in-flight request. - Check
AbortErrorin 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
setcall 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 andPromise<T>when returning data.
How do you type the error in a catch block with TypeScript?
catchblocks receiveunknownin TypeScript.- Cast to
Errorexplicitly:(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.