Skip to content
异步操作

异步操作

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 内部动态创建的(不推荐),则需要留意引用稳定性。