普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月25日首页

React 状态管理的架构演进:为什么你不再需要把所有数据塞进全局 Store

2026年1月25日 17:33

React 状态管理的架构演进:为什么你不再需要把所有数据塞进全局 Store

引言:状态管理的困境

如果你写过中大型 React 应用,一定遇到过这样的场景:

// 你的 Redux Store 或 Zustand Store 长这样
const useAppStore = create((set) => ({
  // 用户信息
  currentUser: null,
  setCurrentUser: (user) => set({ currentUser: user }),

  // 简历列表
  resumes: [],
  setResumes: (resumes) => set({ resumes }),

  // 加载状态
  isLoadingUser: false,
  isLoadingResumes: false,

  // 侧边栏状态
  isSidebarOpen: true,
  toggleSidebar: () => set((s) => ({ isSidebarOpen: !s.isSidebarOpen })),

  // 主题
  theme: 'light',
  setTheme: (theme) => set({ theme }),
}));

然后在组件里,你需要这样使用:

function UserProfile() {
  const { currentUser, isLoadingUser, setCurrentUser } = useAppStore();

  useEffect(() => {
    // 每次组件挂载都要手动获取数据
    fetchUser().then(setCurrentUser);
  }, []);

  if (isLoadingUser) return <Spinner />;
  return <div>{currentUser?.name}</div>;
}

看起来一切正常,但随着应用复杂度增长,问题开始暴露:

  1. 数据重复获取:多个组件都需要 currentUser,每个组件都要写 useEffect 去获取
  2. 缓存失效难题:用户在 A 页面更新了信息,B 页面的数据如何同步?
  3. 加载状态爆炸:每个 API 都要手动维护 isLoadingerrordata 三件套
  4. 数据新鲜度迷失:这份数据是 1 秒前的还是 10 分钟前的?该不该重新获取?

更严重的是,你把所有状态都塞进了同一个 Store,Server State(来自 API 的数据)和 Client State(UI 状态)混在一起,导致:

  • 不知道哪些数据该持久化、哪些该丢弃
  • 不知道哪些数据该自动刷新、哪些不需要
  • 状态更新逻辑越来越复杂,reducer 越写越长

本文将展示一种全新的架构思维:不再把所有数据塞进全局 Store,而是根据数据的本质特征,选择专门的工具来管理。这不是工具的选型问题,而是架构理念的升级。

第一部分:重新认识状态的本质

核心原则:Server State ≠ Client State

在 React 应用中,状态只有两种本质:

状态类型 定义 特征 最佳工具 示例
Server State 来自服务器的数据,你不拥有它 异步、需要缓存、会过期、可能被别人修改 TanStack Query (React Query) 用户信息、简历列表、文章数据、商品详情
Client State 仅存在于浏览器中的 UI 状态,你完全拥有它 同步、瞬态、不需要缓存、只你能改 Zustand / Context 侧边栏开关、弹窗显示、视图切换、暗黑模式

这个分类看起来简单,但它决定了你的架构方向。

为什么要分离?

Server State 的本质是远程缓存,它的核心问题是:

  • 如何避免重复请求?
  • 如何知道数据是否过期?
  • 如何在多个组件间共享同一份数据?
  • 如何在后台自动更新数据?

Client State 的本质是本地变量,它的核心问题是:

  • 如何跨组件共享?
  • 是否需要持久化?
  • 如何避免不必要的重渲染?

这两类问题完全不同,用同一套工具(Redux/Zustand)解决,只会让代码越来越乱。

思维转变:从「状态容器」到「数据同步」

传统思维(Redux/Zustand):

API → fetch → dispatch(setData) → Global Store → Component

新思维(React Query):

API ← Component (通过 useQuery 直接订阅)
      ↑
      └─ 自动缓存、去重、更新、共享

核心区别:不再是"获取数据后存到 Store",而是"组件直接订阅数据源"。React Query 会帮你处理所有脏活累活。

第二部分:反模式警示 - 那些年我们踩过的坑

在深入最佳实践之前,先看看哪些做法是必须避免的。

反模式 1:把 API 数据存进 Zustand/Redux

错误示例

//  不要这样做
const useUserStore = create((set) => ({
  currentUser: null,
  isLoading: false,
  error: null,

  fetchUser: async () => {
    set({ isLoading: true });
    try {
      const user = await api.fetchUser();
      set({ currentUser: user, isLoading: false });
    } catch (error) {
      set({ error, isLoading: false });
    }
  },
}));

// 组件 A
function Header() {
  const { currentUser, fetchUser } = useUserStore();
  useEffect(() => { fetchUser(); }, []);
  return <div>{currentUser?.name}</div>;
}

// 组件 B
function Sidebar() {
  const { currentUser, fetchUser } = useUserStore();
  useEffect(() => { fetchUser(); }, []); // 又请求一次?
  return <Avatar src={currentUser?.avatar} />;
}

问题诊断

  1. 每个组件都要手动触发 fetchUser,容易重复请求
  2. 数据新鲜度无法保证(用户信息可能在服务器被修改了)
  3. 缓存逻辑需要手写(如何判断数据是否过期?)
  4. 跨页面共享困难(A 页面获取的数据,B 页面能用吗?)

正确做法(后面会详细展开):

// 使用 React Query
function useCurrentUser() {
  return useQuery({
    queryKey: ['currentUser'],
    queryFn: api.fetchUser,
    staleTime: 5 * 60 * 1000, // 5分钟内认为数据新鲜
  });
}

// 组件 A 和 B 都这样用
function Header() {
  const { data: user } = useCurrentUser(); // 自动共享、自动去重
  return <div>{user?.name}</div>;
}

反模式 2:useEffect 同步 Query 数据到 Store

错误示例

//  双重数据源:既有 Query 又有 Store
function UserProfile() {
  const { data: user } = useQuery({
    queryKey: ['currentUser'],
    queryFn: fetchUser,
  });

  const setUser = useUserStore((s) => s.setUser);

  //  危险!监听 Query 数据变化,手动同步到 Store
  useEffect(() => {
    if (user) {
      setUser(user); // 为什么要这样做?
    }
  }, [user, setUser]);

  // 现在数据有两份:Query 缓存 + Store
  const storeUser = useUserStore((s) => s.currentUser);
  return <div>{storeUser?.name}</div>;
}

问题诊断

  1. 双重数据源:同一份数据既在 Query 缓存又在 Store,哪个是真相?
  2. 同步 Bug:如果 Query 数据更新但 useEffect 没触发怎么办?
  3. 无意义的复杂度:为什么不直接用 user 而要绕一圈存到 Store?

正确做法

// 直接使用 Query 数据,不要同步到 Store
function UserProfile() {
  const { data: user } = useQuery({
    queryKey: ['currentUser'],
    queryFn: fetchUser,
  });

  return <div>{user?.name}</div>; // 就这么简单
}

// 如果其他组件需要,直接用同样的 Hook
function Sidebar() {
  const { data: user } = useQuery({
    queryKey: ['currentUser'],
    queryFn: fetchUser,
  });
  // React Query 会自动共享缓存,不会重复请求
  return <Avatar src={user?.avatar} />;
}

核心原则:Pull, Don't Push

  • 组件主动"拉取"数据(useQuery
  • 获取数据后"推送"到 Store(setStore

反模式 3:把 UI 状态存进 React Query

错误示例

//  React Query 不是万能的,不要滥用
function useSidebarState() {
  return useQuery({
    queryKey: ['sidebarOpen'],
    queryFn: () => true, // 这根本不是异步数据!
    initialData: true,
  });
}

// 更新侧边栏状态
function toggleSidebar() {
  queryClient.setQueryData(['sidebarOpen'], (old) => !old);
}

问题诊断

  1. React Query 是为异步数据设计的,不是通用状态管理器
  2. 浪费了 Query 的缓存、过期、重试等特性(UI 状态不需要这些)
  3. 语义混乱:侧边栏状态不是"查询"出来的

正确做法

// 纯 UI 状态用 Zustand 或 Context
const useUIStore = create((set) => ({
  isSidebarOpen: true,
  toggleSidebar: () => set((s) => ({ isSidebarOpen: !s.isSidebarOpen })),
}));

反模式 4:Mutation 后手动更新 Store 而不是 Invalidate

错误示例

//  手动维护数据一致性
const updateUserMutation = useMutation({
  mutationFn: updateUser,
  onSuccess: (newUser) => {
    // 手动更新 Store
    useUserStore.setState({ currentUser: newUser });

    // 还要手动更新用户列表
    useUserStore.setState((s) => ({
      users: s.users.map((u) => (u.id === newUser.id ? newUser : u)),
    }));

    // 哪里还有用户数据?都要手动更新...
  },
});

问题诊断

  1. 手动同步容易遗漏(还有多少地方用了这个用户数据?)
  2. 数据不一致风险(服务器返回的数据可能和你想的不一样)
  3. 代码维护噩梦(每个 Mutation 都要写一堆同步逻辑)

正确做法

// 让 React Query 重新获取,服务器是唯一真相
const updateUserMutation = useMutation({
  mutationFn: updateUser,
  onSuccess: () => {
    // 标记数据为"过期",React Query 会自动重新获取
    queryClient.invalidateQueries({ queryKey: ['currentUser'] });
    queryClient.invalidateQueries({ queryKey: ['users'] });
  },
});

核心原则:Server is the source of truth

  • Mutation 后告诉 Query "数据脏了,重新拉取"
  • Mutation 后手动同步本地数据(容易出错)

这些反模式的共同点是:试图用通用状态管理工具(Redux/Zustand)解决异步数据管理问题。接下来,我们看看正确的架构应该是什么样。

第三部分:Server State 的正确打开方式 (React Query)

核心认知:QueryKey 就是全局 Store ID

这是最重要的思维转变:在 React Query 中,queryKey 就是状态的唯一标识符,类似于 Redux 中的 Store Key。

// 传统思维:手动创建全局 Store
const useUserStore = create(() => ({
  currentUser: null, // <-- Store Key
}));

// React Query 思维:QueryKey 就是隐式的 Store ID
const { data: currentUser } = useQuery({
  queryKey: ['currentUser'], // <-- 这就是 Store Key
  queryFn: fetchUser,
});

自动共享的魔法

// 组件 A
function Header() {
  const { data } = useQuery({
    queryKey: ['currentUser'],
    queryFn: fetchUser,
  });
  return <div>{data?.name}</div>;
}

// 组件 B(完全独立的组件树)
function Sidebar() {
  const { data } = useQuery({
    queryKey: ['currentUser'], // 相同的 queryKey
    queryFn: fetchUser,
  });
  return <Avatar src={data?.avatar} />;
}

// 结果:
// 1. 只会发送一次请求(自动去重)
// 2. 两个组件共享同一份缓存数据
// 3. 数据更新时,两个组件自动同步

不再需要显式的全局 Store,QueryKey 就是隐式的 Store ID。


最佳实践 1:使用 Factory Pattern 管理 QueryKey

随着应用增长,QueryKey 会越来越多,使用常量对象统一管理:

// src/queries/queryKeys.ts
export const USER_KEYS = {
  all: ['users'] as const,
  currentUser: ['currentUser'] as const,
  byId: (id: string) => ['users', id] as const,
  posts: (userId: string) => ['users', userId, 'posts'] as const,
};

export const RESUME_KEYS = {
  all: ['resumes'] as const,
  byId: (id: string) => ['resumes', id] as const,
  templates: ['resume-templates'] as const,
};

好处

  1. 避免 Key 拼写错误
  2. 方便批量 invalidate(如 invalidateQueries({ queryKey: USER_KEYS.all })
  3. 清晰的数据模型索引

最佳实践 2:封装自定义 Hook(Pull, Don't Push)

不要在组件里直接写 useQuery,封装成语义化的 Hook:

// src/queries/useUserQueries.ts
export function useCurrentUser() {
  return useQuery({
    queryKey: USER_KEYS.currentUser,
    queryFn: api.fetchUserProfile,
    staleTime: 1000 * 60 * 5, // 5分钟内认为数据新鲜,不重复请求
  });
}

export function useUserPosts(userId: string) {
  return useQuery({
    queryKey: USER_KEYS.posts(userId),
    queryFn: () => api.fetchUserPosts(userId),
    enabled: !!userId, // 只有 userId 存在时才执行查询
  });
}

组件使用

function UserProfile() {
  const { data: user, isLoading, error } = useCurrentUser();

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;

  return (
    <div>
      <h1>{user.name}</h1>
      <UserPosts userId={user.id} />
    </div>
  );
}

function UserPosts({ userId }: { userId: string }) {
  const { data: posts } = useUserPosts(userId);
  // 如果 Header 组件也需要用户信息,直接调用 useCurrentUser()
  // React Query 会自动共享缓存,不会重复请求
  return <PostList posts={posts} />;
}

核心原则:组件主动"拉取"所需数据,而不是等待数据"推送"过来。


最佳实践 3:理解 staleTime vs cacheTime

这是新手最容易混淆的概念:

useQuery({
  queryKey: ['data'],
  queryFn: fetchData,
  staleTime: 5 * 60 * 1000, // 数据在 5 分钟内认为是"新鲜"的
  cacheTime: 10 * 60 * 1000, // 数据在 10 分钟后从缓存中删除
});
配置 含义 触发行为
staleTime 数据多久后变"陈旧" 陈旧数据会在组件重新挂载/窗口重新聚焦时自动重新获取
cacheTime 数据多久后从缓存删除 删除后下次查询会发送新请求

典型配置

// 用户信息:不常变,可以缓存久一点
export function useCurrentUser() {
  return useQuery({
    queryKey: USER_KEYS.currentUser,
    queryFn: fetchUser,
    staleTime: 5 * 60 * 1000, // 5分钟
    cacheTime: 10 * 60 * 1000, // 10分钟
  });
}

// 实时数据:需要频繁更新
export function useRealtimeStats() {
  return useQuery({
    queryKey: ['stats'],
    queryFn: fetchStats,
    staleTime: 0, // 总是认为数据陈旧,会频繁刷新
    refetchInterval: 10000, // 每 10 秒自动刷新
  });
}

最佳实践 4:避免手动同步到 Zustand

再次强调:如果你发现自己在写这种代码,立刻停下来重新审视架构:

//  绝对不要这样做
function UserProfile() {
  const { data } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
  const setUser = useUserStore((s) => s.setUser);

  useEffect(() => {
    if (data) setUser(data); //  双重数据源警告!
  }, [data, setUser]);
}

如果你需要在多个组件访问同一份 Query 数据,正确做法是

  1. 封装自定义 Hook(推荐):
// 任何组件都可以直接调用
export function useCurrentUser() {
  return useQuery({ queryKey: ['currentUser'], queryFn: fetchUser });
}
  1. 如果真的需要 Zustand 存储衍生状态(极少数场景):
// 只存储"选择"或"临时标记",不存储 API 数据本身
const useSelectionStore = create((set) => ({
  selectedUserId: null, // 只存 ID,不存整个 user 对象
  setSelectedUserId: (id) => set({ selectedUserId: id }),
}));

function UserList() {
  const { data: users } = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
  const setSelectedUserId = useSelectionStore((s) => s.setSelectedUserId);

  return users.map((user) => (
    <UserCard
      key={user.id}
      user={user}
      onClick={() => setSelectedUserId(user.id)} // 只存 ID
    />
  ));
}

function UserDetail() {
  const selectedUserId = useSelectionStore((s) => s.selectedUserId);
  // 根据 ID 重新查询完整数据
  const { data: user } = useQuery({
    queryKey: ['users', selectedUserId],
    queryFn: () => fetchUser(selectedUserId),
    enabled: !!selectedUserId,
  });
}

原则:Zustand 可以存"引用"(ID、索引),但不要存 API 数据本身。

第四部分:Client State 的克制使用 (Zustand)

在 React Query 接管了所有 Server State 后,Zustand 应该变得非常轻量。如果你的 Zustand Store 还是很庞大,说明架构可能有问题。

Zustand 的正确定位:纯 UI 状态

Zustand 只应该用于两类场景:

1. 纯 UI 控制状态
// src/store/useUIStore.ts
interface UIState {
  // 侧边栏
  isSidebarOpen: boolean;
  toggleSidebar: () => void;

  // 主题
  theme: 'light' | 'dark';
  setTheme: (theme: 'light' | 'dark') => void;

  // 视图模式
  viewMode: 'grid' | 'list';
  setViewMode: (mode: 'grid' | 'list') => void;

  // 弹窗
  activeModal: string | null;
  openModal: (id: string) => void;
  closeModal: () => void;
}

export const useUIStore = create<UIState>((set) => ({
  isSidebarOpen: true,
  toggleSidebar: () => set((s) => ({ isSidebarOpen: !s.isSidebarOpen })),

  theme: 'light',
  setTheme: (theme) => set({ theme }),

  viewMode: 'grid',
  setViewMode: (mode) => set({ viewMode: mode }),

  activeModal: null,
  openModal: (id) => set({ activeModal: id }),
  closeModal: () => set({ activeModal: null }),
}));

使用示例

function Header() {
  const { isSidebarOpen, toggleSidebar } = useUIStore();
  return <IconButton onClick={toggleSidebar} icon={isSidebarOpen ? 'close' : 'menu'} />;
}

function Sidebar() {
  const isSidebarOpen = useUIStore((s) => s.isSidebarOpen);
  return <aside className={isSidebarOpen ? 'open' : 'closed'}>...</aside>;
}

特点

  • 100% 同步
  • 不涉及 API 调用
  • 不需要加载状态
  • 完全由客户端控制

2. 多步操作的临时 Session 数据

典型场景:多步表单(Wizard),第一步的数据还没提交到服务器,但后续步骤需要用到。

// src/store/useResumeWizardStore.ts
interface ResumeWizardState {
  // 临时数据(还没提交到服务器)
  draftBasicInfo: { name: string; email: string } | null;
  draftExperience: Experience[] | null;
  currentStep: number;

  // Actions
  saveDraftBasicInfo: (data: BasicInfo) => void;
  saveDraftExperience: (data: Experience[]) => void;
  nextStep: () => void;
  reset: () => void;
}

export const useResumeWizardStore = create<ResumeWizardState>((set) => ({
  draftBasicInfo: null,
  draftExperience: null,
  currentStep: 1,

  saveDraftBasicInfo: (data) => set({ draftBasicInfo: data }),
  saveDraftExperience: (data) => set({ draftExperience: data }),
  nextStep: () => set((s) => ({ currentStep: s.currentStep + 1 })),
  reset: () => set({ draftBasicInfo: null, draftExperience: null, currentStep: 1 }),
}));

使用示例

// 第一步:填写基本信息
function Step1BasicInfo() {
  const { saveDraftBasicInfo, nextStep } = useResumeWizardStore();

  const handleNext = (formData: BasicInfo) => {
    saveDraftBasicInfo(formData); // 暂存到 Zustand
    nextStep();
  };

  return <BasicInfoForm onSubmit={handleNext} />;
}

// 第二步:填写工作经验(需要用到第一步的数据)
function Step2Experience() {
  const { draftBasicInfo, saveDraftExperience, nextStep } = useResumeWizardStore();

  const handleNext = (formData: Experience[]) => {
    saveDraftExperience(formData);
    nextStep();
  };

  return (
    <div>
      <p>为 {draftBasicInfo?.name} 添加工作经验</p>
      <ExperienceForm onSubmit={handleNext} />
    </div>
  );
}

// 最后一步:提交到服务器
function Step3Submit() {
  const { draftBasicInfo, draftExperience, reset } = useResumeWizardStore();

  const createResumeMutation = useMutation({
    mutationFn: (data: CreateResumeDTO) => api.createResume(data),
    onSuccess: () => {
      reset(); // 清空临时数据
      queryClient.invalidateQueries({ queryKey: RESUME_KEYS.all }); // 刷新简历列表
      navigate('/resumes');
    },
  });

  const handleSubmit = () => {
    createResumeMutation.mutate({
      basicInfo: draftBasicInfo,
      experience: draftExperience,
    });
  };

  return <SubmitButton onClick={handleSubmit} loading={createResumeMutation.isPending} />;
}

核心原则

  • Zustand 存储临时数据(还未提交的表单)
  • Mutation 成功后立即清空临时数据
  • Mutation 成功后 invalidate Query,让列表自动刷新

何时不应该用 Zustand?

不要存储 API 数据
//  错误:把用户信息存在 Zustand
const useUserStore = create(() => ({
  currentUser: null,
  setCurrentUser: (user) => set({ currentUser: user }),
}));

// 正确:用 React Query
function useCurrentUser() {
  return useQuery({ queryKey: ['currentUser'], queryFn: fetchUser });
}
不要存储可以计算出来的数据
//  错误:存储派生状态
const useCartStore = create(() => ({
  items: [],
  totalPrice: 0, // 可以从 items 计算出来!
  setItems: (items) => {
    const totalPrice = items.reduce((sum, item) => sum + item.price, 0);
    set({ items, totalPrice });
  },
}));

// 正确:存原始数据,派生数据用 selector 计算
const useCartStore = create(() => ({
  items: [],
  setItems: (items) => set({ items }),
}));

function useCartTotal() {
  return useCartStore((s) => s.items.reduce((sum, item) => sum + item.price, 0));
}
不要滥用持久化
//  错误:持久化所有东西
const useStore = create(
  persist(
    (set) => ({
      currentUser: null, // API 数据不要持久化!
      resumes: [], // API 数据不要持久化!
      theme: 'light', // 这个可以持久化
      isSidebarOpen: true, // ❓ 这个需要持久化吗?
    }),
    { name: 'app-storage' }
  )
);

// 正确:只持久化用户偏好
const useUIStore = create(
  persist(
    (set) => ({
      theme: 'light',
      viewMode: 'grid',
      setTheme: (theme) => set({ theme }),
      setViewMode: (mode) => set({ viewMode: mode }),
    }),
    {
      name: 'ui-preferences',
      // 只持久化这两个字段
      partialize: (state) => ({ theme: state.theme, viewMode: state.viewMode }),
    }
  )
);

Zustand 的理想状态

在正确使用 React Query 后,你的 Zustand Store 应该非常小

// 整个应用可能只需要一个 UI Store
export const useUIStore = create(
  persist(
    (set) => ({
      // 主题
      theme: 'light' as 'light' | 'dark',
      setTheme: (theme: 'light' | 'dark') => set({ theme }),

      // 侧边栏
      isSidebarCollapsed: false,
      toggleSidebar: () => set((s) => ({ isSidebarCollapsed: !s.isSidebarCollapsed })),

      // 视图偏好
      resumeViewMode: 'grid' as 'grid' | 'list',
      setResumeViewMode: (mode: 'grid' | 'list') => set({ resumeViewMode: mode }),
    }),
    {
      name: 'ui-preferences',
      partialize: (state) => ({
        theme: state.theme,
        resumeViewMode: state.resumeViewMode,
      }),
    }
  )
);

如果你的 Zustand Store 超过 100 行代码,很可能有架构问题。

第五部分:Mutation - 连接两个世界的桥梁

useMutation 虽然不缓存数据,但它是连接 Server State 和 Client State 的关键桥梁。

标准模式:Mutation + Invalidation

这是最常见、最安全的模式:

// 更新用户信息
function useUpdateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: UpdateUserDTO) => api.updateUser(data),

    onSuccess: () => {
      // 告诉 React Query:"用户数据过期了,重新拉取"
      queryClient.invalidateQueries({ queryKey: USER_KEYS.currentUser });
      queryClient.invalidateQueries({ queryKey: USER_KEYS.all });
    },

    onError: (error) => {
      toast.error(`更新失败: ${error.message}`);
    },
  });
}

// 组件使用
function UserProfileForm() {
  const { data: user } = useCurrentUser();
  const updateUserMutation = useUpdateUser();

  const handleSubmit = (formData: UpdateUserDTO) => {
    updateUserMutation.mutate(formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input defaultValue={user?.name} name="name" />
      <button type="submit" disabled={updateUserMutation.isPending}>
        {updateUserMutation.isPending ? '保存中...' : '保存'}
      </button>
    </form>
  );
}

核心原则:Server is the source of truth

  • Mutation 成功后,让服务器告诉我们最新数据是什么(通过重新查询)
  • 不要自己猜测服务器返回什么数据(手动更新 Store)

高级模式:乐观更新 (Optimistic Update)

对于用户体验要求高的场景(如点赞、收藏),可以先更新 UI,失败后再回滚:

function useLikePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (postId: string) => api.likePost(postId),

    // 在请求发送前立即更新 UI
    onMutate: async (postId) => {
      // 1. 取消正在进行的查询(避免覆盖乐观更新)
      await queryClient.cancelQueries({ queryKey: ['posts', postId] });

      // 2. 保存当前数据(用于回滚)
      const previousPost = queryClient.getQueryData(['posts', postId]);

      // 3. 乐观更新
      queryClient.setQueryData(['posts', postId], (old: Post) => ({
        ...old,
        isLiked: true,
        likeCount: old.likeCount + 1,
      }));

      // 返回回滚上下文
      return { previousPost };
    },

    // 如果失败,回滚
    onError: (error, postId, context) => {
      queryClient.setQueryData(['posts', postId], context.previousPost);
      toast.error('点赞失败');
    },

    // 成功后,重新获取确保数据一致
    onSettled: (data, error, postId) => {
      queryClient.invalidateQueries({ queryKey: ['posts', postId] });
    },
  });
}

使用场景

  • 用户交互频繁(点赞、收藏、切换状态)
  • 操作成功率高(网络正常时几乎不会失败)
  • 复杂业务逻辑(服务器可能返回完全不同的数据)
  • 金钱交易(必须等服务器确认)

Mutation 与 Zustand 的协作

Mutation 是更新 Zustand 临时数据的最佳时机:

// 登录场景:先登录,再存 token 到 Store
function useLogin() {
  const setAuthToken = useAuthStore((s) => s.setToken);
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (credentials: LoginDTO) => api.login(credentials),

    onSuccess: (response) => {
      // 1. 存 token 到 Zustand(临时 Session 数据)
      setAuthToken(response.accessToken);

      // 2. 触发用户信息查询
      queryClient.invalidateQueries({ queryKey: USER_KEYS.currentUser });
    },
  });
}

// 组件使用
function LoginForm() {
  const loginMutation = useLogin();
  const navigate = useNavigate();

  const handleSubmit = async (formData: LoginDTO) => {
    await loginMutation.mutateAsync(formData);
    // mutateAsync 保证 onSuccess 执行完毕后才继续
    navigate('/dashboard'); // 此时 token 已经存好,可以安全跳转
  };

  return <form onSubmit={handleSubmit}>...</form>;
}

mutateAsync vs mutate 的区别

// mutate: 触发即忘(fire-and-forget)
loginMutation.mutate(formData);
navigate('/dashboard'); //  可能 token 还没存好就跳转了

// mutateAsync: 等待完成(包括 onSuccess)
await loginMutation.mutateAsync(formData);
navigate('/dashboard'); // onSuccess 已执行,token 已存好

使用建议

  • 大部分场景用 mutate(React Query 会处理好时序)
  • 需要线性执行流程时用 mutateAsync(登录后跳转、提交后关闭弹窗)

批量 Invalidation 策略

当一个 Mutation 影响多个查询时,使用数组形式的 QueryKey 批量失效:

// QueryKey 设计:层级结构
export const RESUME_KEYS = {
  all: ['resumes'] as const, // 所有简历相关
  lists: () => [...RESUME_KEYS.all, 'list'] as const, // 简历列表
  details: () => [...RESUME_KEYS.all, 'detail'] as const, // 简历详情
  detail: (id: string) => [...RESUME_KEYS.details(), id] as const,
};

// 删除简历
function useDeleteResume() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) => api.deleteResume(id),

    onSuccess: (_, deletedId) => {
      // 1. 失效列表查询
      queryClient.invalidateQueries({ queryKey: RESUME_KEYS.lists() });

      // 2. 直接移除详情缓存(不需要重新获取不存在的数据)
      queryClient.removeQueries({ queryKey: RESUME_KEYS.detail(deletedId) });
    },
  });
}

层级 QueryKey 的好处

// 失效所有简历相关查询
queryClient.invalidateQueries({ queryKey: ['resumes'] });

// 只失效简历列表
queryClient.invalidateQueries({ queryKey: ['resumes', 'list'] });

// 只失效某个简历详情
queryClient.invalidateQueries({ queryKey: ['resumes', 'detail', 'resume-123'] });

错误处理最佳实践

function useCreateResume() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: CreateResumeDTO) => api.createResume(data),

    onSuccess: (newResume) => {
      // 成功:刷新列表
      queryClient.invalidateQueries({ queryKey: RESUME_KEYS.lists() });

      // 可选:直接插入新数据到缓存(避免重新请求)
      queryClient.setQueryData(RESUME_KEYS.detail(newResume.id), newResume);

      toast.success('简历创建成功');
    },

    onError: (error: ApiError) => {
      // 错误处理:根据错误类型给出不同提示
      if (error.code === 'QUOTA_EXCEEDED') {
        toast.error('您的简历数量已达上限,请升级会员');
      } else if (error.code === 'VALIDATION_ERROR') {
        toast.error(`数据验证失败: ${error.message}`);
      } else {
        toast.error('创建失败,请稍后重试');
      }
    },
  });
}

原则

  • 区分业务错误和网络错误
  • 给用户明确的错误提示和行动建议
  • 不要默默吞掉错误

第六部分:实战决策树与最佳实践

当你面对一个状态时,按照以下决策树快速判断应该用什么工具:

决策流程图

开始:我需要管理一个状态
    ↓
┌───▼───────────────────────────┐
│ 这个数据来自 API 吗?            │
└───┬───────────────────────────┘
    │
    ├─ 是 → 使用 React Query
    │       ├─ 封装 useQuery Hook
    │       ├─ 定义 QueryKey 常量
    │       └─ 配置 staleTime/cacheTime
    │
    └─ 否 → 数据只是 UI 状态?
            │
            ├─ 是 → 需要跨组件共享吗?
            │       │
            │       ├─ 是 → Zustand
            │       │       └─ 只存储纯 UI 控制状态
            │       │
            │       └─ 否 → useState/useReducer
            │               └─ 组件本地状态即可
            │
            └─ 否 → 是多步操作的临时数据?
                    │
                    ├─ 是 → Zustand(临时 Session)
                    │       └─ Mutation 成功后清空
                    │
                    └─ 否 → 重新审视需求
                            └─ 可能不需要状态管理

实战案例速查表

场景 使用工具 示例代码
获取用户信息 React Query useQuery({ queryKey: ['user'], queryFn: fetchUser })
获取简历列表 React Query useQuery({ queryKey: ['resumes'], queryFn: fetchResumes })
主题切换(dark/light) Zustand + persist create(persist(...))
侧边栏展开/收起 Zustand { isSidebarOpen, toggleSidebar }
表单输入值 useState const [value, setValue] = useState('')
多步表单向导 Zustand (临时) { draftData, saveDraft, reset }
当前路由参数 React Router useParams() / useSearchParams()
用户登录状态 React Query useQuery({ queryKey: ['session'] })
Token 存储 Zustand (不持久化) { token, setToken }
弹窗显示/隐藏 Zustand 或 useState 取决于是否跨组件

常见场景深度解析

场景 1:用户认证状态
// 正确:Session 数据用 Query,Token 用 Zustand
export const useAuthStore = create<AuthState>((set) => ({
  accessToken: null,
  setToken: (token) => set({ accessToken: token }),
  clearToken: () => set({ accessToken: null }),
}));

// 用户信息用 Query
export function useCurrentUser() {
  const token = useAuthStore((s) => s.accessToken);

  return useQuery({
    queryKey: USER_KEYS.currentUser,
    queryFn: fetchCurrentUser,
    enabled: !!token, // 只有 token 存在时才查询
  });
}

// 登录
function useLogin() {
  const setToken = useAuthStore((s) => s.setToken);
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: api.login,
    onSuccess: (response) => {
      setToken(response.accessToken); // Token 存 Zustand
      queryClient.invalidateQueries({ queryKey: USER_KEYS.currentUser }); // 触发用户信息查询
    },
  });
}

// 登出
function useLogout() {
  const clearToken = useAuthStore((s) => s.clearToken);
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: api.logout,
    onSuccess: () => {
      clearToken(); // 清除 token
      queryClient.clear(); // 清空所有 Query 缓存
    },
  });
}

原则

  • Token(凭证)→ Zustand(临时 Session)
  • 用户信息(数据)→ React Query(Server State)

场景 2:列表筛选与排序
//  错误:把筛选参数和列表数据都存在 Zustand
const useResumeStore = create(() => ({
  resumes: [],
  filters: { search: '', sortBy: 'date' },
  setFilters: (filters) => set({ filters }),
  fetchResumes: async (filters) => {
    const resumes = await api.fetchResumes(filters);
    set({ resumes });
  },
}));

// 正确:筛选参数用 URL,列表用 Query
function ResumeList() {
  const [searchParams, setSearchParams] = useSearchParams();
  const search = searchParams.get('search') || '';
  const sortBy = searchParams.get('sortBy') || 'date';

  // 根据 URL 参数查询
  const { data: resumes } = useQuery({
    queryKey: ['resumes', { search, sortBy }], // QueryKey 包含筛选参数
    queryFn: () => api.fetchResumes({ search, sortBy }),
  });

  const handleSearchChange = (newSearch: string) => {
    setSearchParams({ search: newSearch, sortBy });
  };

  return (
    <div>
      <SearchInput value={search} onChange={handleSearchChange} />
      <ResumeGrid resumes={resumes} />
    </div>
  );
}

好处

  • URL 可分享(别人打开链接就能看到相同的筛选结果)
  • 浏览器前进/后退按钮自动工作
  • 不需要额外的状态管理

场景 3:购物车
// 如果购物车存在服务器(已登录用户)
function useCart() {
  return useQuery({
    queryKey: ['cart'],
    queryFn: api.fetchCart,
  });
}

function useAddToCart() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (productId: string) => api.addToCart(productId),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['cart'] });
    },
  });
}

// 如果购物车只在浏览器本地(未登录)
const useCartStore = create(
  persist(
    (set) => ({
      items: [],
      addItem: (product) => set((s) => ({ items: [...s.items, product] })),
      removeItem: (productId) =>
        set((s) => ({ items: s.items.filter((item) => item.id !== productId) })),
    }),
    { name: 'guest-cart' }
  )
);

决策依据:数据的"真相"在哪里?

  • 服务器 → React Query
  • 浏览器 → Zustand + persist

持久化策略

React Query 持久化
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';

const persister = createSyncStoragePersister({
  storage: window.localStorage,
});

persistQueryClient({
  queryClient,
  persister,
  maxAge: 1000 * 60 * 60 * 24, // 24 小时
  dehydrateOptions: {
    // 只持久化特定查询
    shouldDehydrateQuery: (query) => {
      const queryKey = query.queryKey[0];
      // 只持久化用户信息和配置,不持久化列表数据
      return queryKey === 'currentUser' || queryKey === 'appConfig';
    },
  },
});

持久化原则

  • 用户信息、应用配置(低频变化)
  • 列表数据、实时数据(高频变化)
  • 敏感数据(Token 用 httpOnly cookie)
Zustand 持久化
const useUIStore = create(
  persist(
    (set) => ({
      theme: 'light',
      sidebarCollapsed: false,
      recentSearches: [],

      setTheme: (theme) => set({ theme }),
      toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
      addRecentSearch: (query) =>
        set((s) => ({
          recentSearches: [query, ...s.recentSearches].slice(0, 10),
        })),
    }),
    {
      name: 'ui-preferences',
      // 部分字段持久化
      partialize: (state) => ({
        theme: state.theme,
        recentSearches: state.recentSearches,
        // sidebarCollapsed 不持久化,每次打开都是展开状态
      }),
    }
  )
);

持久化原则

  • 用户偏好(主题、语言、视图模式)
  • 历史记录(最近搜索、最近访问)
  • 临时 UI 状态(弹窗、侧边栏)
  • 敏感数据(密码、Token)

性能优化检查清单

React Query 优化
// 合理设置 staleTime(避免过度请求)
useQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
  staleTime: 5 * 60 * 1000, // 用户信息 5 分钟内不重新获取
});

// 使用 select 减少重渲染
function UserAvatar() {
  // 只订阅 avatar 字段,name 变化不会触发重渲染
  const avatar = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser,
    select: (data) => data.avatar,
  });
}

// 禁用不需要的自动行为
useQuery({
  queryKey: ['static-config'],
  queryFn: fetchConfig,
  staleTime: Infinity, // 永不过期
  refetchOnWindowFocus: false, // 窗口聚焦时不刷新
  refetchOnReconnect: false, // 重连时不刷新
});
Zustand 优化
// 使用精确订阅(避免不必要的重渲染)
function ThemeToggle() {
  // 只订阅 theme,其他字段变化不会触发重渲染
  const theme = useUIStore((s) => s.theme);
  const setTheme = useUIStore((s) => s.setTheme);
}

//  错误:订阅整个 Store
function ThemeToggle() {
  const { theme, setTheme, sidebarOpen, viewMode } = useUIStore();
  // sidebarOpen 或 viewMode 变化也会触发重渲染!
}

// 使用 shallow 比较(对象或数组)
import { shallow } from 'zustand/shallow';

function UserSettings() {
  const { theme, viewMode } = useUIStore(
    (s) => ({ theme: s.theme, viewMode: s.viewMode }),
    shallow // 浅比较,避免引用变化导致重渲染
  );
}

迁移路径:从 Redux 到 React Query + Zustand

如果你有一个使用 Redux 的老项目,可以这样逐步迁移:

第 1 步:识别状态类型

// 现有 Redux Store
const store = {
  user: { ... },           // Server State → React Query
  resumes: [ ... ],        // Server State → React Query
  ui: {
    theme: 'light',        // Client State → Zustand
    sidebarOpen: true,     // Client State → Zustand
  },
};

第 2 步:迁移 Server State

// 删除 Redux Slice
- import { selectUser } from './userSlice';
+ import { useCurrentUser } from './queries/useUserQueries';

function UserProfile() {
-  const user = useSelector(selectUser);
+  const { data: user } = useCurrentUser();
}

第 3 步:迁移 Client State

// 创建 Zustand Store
const useUIStore = create((set) => ({
  theme: 'light',
  sidebarOpen: true,
  setTheme: (theme) => set({ theme }),
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
}));

// 替换使用
- const theme = useSelector((s) => s.ui.theme);
- const dispatch = useDispatch();
+ const { theme, setTheme } = useUIStore();

第 4 步:删除 Redux

// 当所有状态迁移完成后,删除 Redux 相关代码
- import { Provider } from 'react-redux';
- import { store } from './store';

// 只保留 React Query
+ import { QueryClientProvider } from '@tanstack/react-query';

迁移收益

  • 代码量减少 30-50%
  • 去除大量 boilerplate(actions, reducers, selectors)
  • 自动获得缓存、去重、后台刷新等能力

总结:架构的本质是分离关注点

回到开篇的问题:为什么状态管理会变得混乱?

根本原因是:我们用同一套工具(Redux/Zustand)解决了本质不同的问题。

本文提出的架构理念可以总结为:

核心原则

  1. Server State ≠ Client State

    • Server State:异步、需要缓存、会过期、可能被别人修改
    • Client State:同步、瞬态、完全由客户端控制
  2. 工具专注于各自的问题域

    • React Query:专注解决异步数据管理(缓存、去重、更新、同步)
    • Zustand:专注解决跨组件的 UI 状态共享
  3. Pull, Don't Push

    • 组件主动"拉取"所需数据(useQuery
    • 而不是获取数据后"推送"到全局 Store
  4. Server is the source of truth

    • Mutation 后让服务器告诉我们最新数据(invalidate + refetch)
    • 而不是自己猜测并手动同步本地数据

架构收益

采用这套架构后,你会发现:

代码更简洁

// 之前:Redux + 手动管理缓存
// userSlice.ts (50 行)
// userActions.ts (30 行)
// userSaga.ts (80 行)
// 总计:160 行

// 现在:React Query
// useUserQueries.ts (20 行)
export function useCurrentUser() {
  return useQuery({
    queryKey: ['currentUser'],
    queryFn: fetchUser,
    staleTime: 5 * 60 * 1000,
  });
}

Bug 自然消失

  • 不再有"数据不同步"(Query 自动处理)
  • 不再有"重复请求"(自动去重)
  • 不再有"数据过期但不知道"(staleTime 机制)

开发体验提升

  • 新功能开发时,不需要先写 action → reducer → selector
  • 直接在组件里 useQuery,数据立即可用
  • DevTools 直观展示缓存状态、请求时序

性能优化

  • 自动后台刷新(用户无感知获取最新数据)
  • 智能去重(多个组件同时请求只发一次)
  • 按需加载(只有组件挂载时才查询数据)

最后的建议

  1. 不要一次性重构

    • 新功能直接用 React Query
    • 老代码逐步迁移(从最简单的查询开始)
  2. 不要教条主义

    • 如果团队已经深度使用 Redux 且运转良好,不一定要迁移
    • 但新项目强烈建议采用 React Query + Zustand
  3. 不要过度设计

    • 小型项目甚至可以只用 React Query + Context
    • Zustand 只在确实需要时引入
  4. 关注数据的本质,而不是工具

    • 先判断数据类型(Server State vs Client State)
    • 再选择合适的工具

扩展阅读


最后一句话:状态管理不应该是痛苦的,选对工具,架构自然清晰。


如果这篇文章对你有帮助,欢迎点赞收藏。如果你有不同看法或实践经验,也欢迎在评论区讨论。

本文示例代码基于 React 18 + TypeScript + TanStack Query v5 + Zustand v4

❌
❌