异步操作
Zustand 的 action 可以是同步的,也可以是异步的。异步操作不需要特殊 API——直接在 action 里写 async/await 即可。
基础异步 Action
import { create } from 'zustand';
interface User {
id: number;
name: string;
email: string;
}
interface UserState {
user: User | null;
loading: boolean;
error: string | null;
fetchUser: (id: number) => Promise<void>;
}
const useUserStore = create<UserState>((set, get) => ({
user: null,
loading: false,
error: null,
fetchUser: async (id: number) => {
set({ loading: true, error: null }); // 开始加载
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const user = await res.json();
set({ user, loading: false }); // 成功
} catch (err) {
set({ error: (err as Error).message, loading: false }); // 失败
}
},
}));与 Redux 的 createAsyncThunk 不同,Zustand 不需要模板代码——直接写 async/await,在 await 前后调用 set() 即可。
完整的异步状态管理
interface AsyncState<T> {
data: T | null;
loading: boolean;
error: string | null;
// 派生状态
idle: boolean;
success: boolean;
}
interface ProductStore {
products: AsyncState<Product[]>;
fetchProducts: () => Promise<void>;
}
const useProductStore = create<ProductStore>((set) => ({
products: {
data: null,
loading: false,
error: null,
get idle() { return !this.loading && !this.data && !this.error; },
get success() { return !this.loading && !!this.data && !this.error; },
},
fetchProducts: async () => {
set((s) => ({ products: { ...s.products, loading: true, error: null } }));
try {
const res = await fetch('/api/products');
const data = await res.json();
set((s) => ({ products: { ...s.products, data, loading: false } }));
} catch (err) {
set((s) => ({
products: { ...s.products, error: (err as Error).message, loading: false },
}));
}
},
}));🚨 陷阱:
get()不能代替set的函数式更新在异步场景中。如果 action 在await之后需要基于最新状态计算——用get():fetchAndMerge: async () => { const res = await fetch('/api/data'); const newData = await res.json(); // get() 拿到的是 await 之后的最新 state(可能有其他 action 修改过) const currentData = get().data; set({ data: { ...currentData, ...newData } }); },
竞态条件处理
当用户快速切换/连续请求时,旧请求的结果可能覆盖新请求:
const useSearchStore = create<SearchState>((set, get) => ({
results: [],
loading: false,
// ❌ 问题:没有处理竞态
search_buggy: async (query: string) => {
set({ loading: true });
const results = await fetch(`/api/search?q=${query}`).then((r) => r.json());
set({ results, loading: false }); // 旧请求可能后到达,覆盖新结果
},
}));方案 1:AbortController(推荐)
const useSearchStore = create<SearchState>((set, get) => ({
results: [],
loading: false,
abortController: null as AbortController | null,
search: async (query: string) => {
// 取消上一次请求
get().abortController?.abort();
const controller = new AbortController();
set({ loading: true, abortController: controller });
try {
const res = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
const results = await res.json();
set({ results, loading: false });
} catch (err) {
if ((err as Error).name !== 'AbortError') {
set({ loading: false });
}
}
},
}));方案 2:请求 ID 标记
const useSearchStore = create<SearchState>((set, get) => ({
results: [],
loading: false,
requestId: 0,
search: async (query: string) => {
const id = get().requestId + 1;
set({ loading: true, requestId: id });
const results = await fetch(`/api/search?q=${query}`).then((r) => r.json());
// 只有最新的请求才生效
if (id === get().requestId) {
set({ results, loading: false });
}
},
}));💡 最佳实践:优先使用
AbortController——它不仅解决了 UI 层面的竞态问题,还会真正取消网络请求(节省带宽)。请求 ID 方案只在无法使用 AbortController 时作为备选。
并行请求
const useDashboardStore = create<DashboardState>((set) => ({
users: [],
orders: [],
stats: null,
loading: false,
loadDashboard: async () => {
set({ loading: true });
try {
// 并行请求
const [users, orders, stats] = await Promise.all([
fetch('/api/users').then((r) => r.json()),
fetch('/api/orders').then((r) => r.json()),
fetch('/api/stats').then((r) => r.json()),
]);
set({ users, orders, stats, loading: false });
} catch (err) {
set({ loading: false });
// 可以分别处理每个请求的错误
}
},
}));乐观更新
const useTodoStore = create<TodoState>((set, get) => ({
todos: [],
toggleTodo: async (id: number) => {
// 1. 获取旧值(失败时回滚用)
const prevTodos = get().todos;
// 2. 乐观更新:先立即更新 UI
set((s) => ({
todos: s.todos.map((t) =>
t.id === id ? { ...t, done: !t.done } : t
),
}));
try {
// 3. 发送请求
await fetch(`/api/todos/${id}/toggle`, { method: 'PATCH' });
} catch {
// 4. 请求失败 → 回滚
set({ todos: prevTodos });
}
},
}));💡 最佳实践:乐观更新能显著提升用户体验——用户点击后 UI 立即响应,不等网络。但必须实现回滚逻辑(请求失败时恢复旧状态)。
异步 Action 的 loading/error 模式总结
三种常见模式:
// 模式 1:每字段独立管理(适合简单场景)
interface Store {
user: User | null;
userLoading: boolean;
userError: string | null;
}
// 模式 2:AsyncState 泛型封装(适合重复场景)
interface AsyncState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
interface Store {
user: AsyncState<User>;
products: AsyncState<Product[]>;
}
// 模式 3:用 TanStack Query 管理服务端状态(适合复杂场景)
// Zustand 只管理客户端状态,服务端状态交给 React Query
💡 最佳实践:如果 store 中有大量异步数据获取逻辑,考虑混合方案:Zustand 管理 UI 状态(选中项、展开/折叠、表单输入等),TanStack Query 管理服务端数据(缓存、重新获取、乐观更新等)。参考
react/06-状态管理.md的服务端状态 vs 客户端状态讨论。
异步 Action 的测试友好设计
// ❌ 硬编码 fetch → 难以测试
const useStore = create((set) => ({
data: null,
load: async () => {
const data = await fetch('/api/data').then((r) => r.json());
set({ data });
},
}));
// ✅ 通过参数注入 fetcher → 测试时传入 mock
const useStore = create<{
data: unknown;
load: (fetcher?: typeof fetch) => Promise<void>;
}>((set) => ({
data: null,
load: async (fetcher = fetch) => {
const data = await fetcher('/api/data').then((r: Response) => r.json());
set({ data });
},
}));
// 测试中
const mockFetcher = async () => new Response(JSON.stringify({ id: 1 }));
await useStore.getState().load(mockFetcher);在组件中使用异步 Action
function UserProfile({ userId }: { userId: number }) {
const user = useUserStore((s) => s.user);
const loading = useUserStore((s) => s.loading);
const error = useUserStore((s) => s.error);
const fetchUser = useUserStore((s) => s.fetchUser);
useEffect(() => {
fetchUser(userId);
}, [userId, fetchUser]);
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
if (!user) return null;
return <div>{user.name}</div>;
}🚨 陷阱:
fetchUser是 action——store 创建时就确定的稳定引用。因此它作为useEffect的依赖是安全的(不会导致死循环)。但如果 action 是在 store 内部动态创建的(不推荐),则需要留意引用稳定性。