//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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 });
}
},
}));// 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>
);
}set can be called multiple times in an async action: once to set loading, once to set data or error.set call triggers a synchronous state update. React 18+ batches renders, so rapid sequential set calls may be batched.get() to snapshot current state before the async operation, then roll back on failure.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 });
}
}
},
};
});Promise<void> or Promise<T> and should be typed accordingly.(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>;
}isLoading boolean shared across multiple async actions can conflict. If fetchProducts and fetchProduct both use the same isLoading, one can overwrite the other.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.set call still updates the store (no error, but the old component's effect cleanup is gone).| 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 |
async function that calls set at different points (loading, success, error).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 });
}
},get() to snapshot the current state before the async operation.set.set with the snapshot.Promise<T>.await the result, e.g., to navigate after a successful create.fetchProducts and fetchProduct both set isLoading, one can overwrite the other's loading state.loading: Record<string, boolean>) to avoid this.AbortController to cancel the previous in-flight request.AbortError in the catch block and ignore it.controller?.abort();
controller = new AbortController();
const res = await fetch(url, { signal: controller.signal });set call still updates the store -- Zustand stores live outside React.interface UserStore {
users: User[];
isLoading: boolean;
error: string | null;
fetchUsers: () => Promise<void>;
createUser: (input: Omit<User, "id">) => Promise<User>;
}Promise<void> for fire-and-forget actions and Promise<T> when returning data.catch blocks receive unknown in TypeScript.Error explicitly: (err as Error).message.set({ isLoading: true }) / try / catch / set({ isLoading: false }) pattern into a reusable function.Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥