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>;
}
看起来一切正常,但随着应用复杂度增长,问题开始暴露:
- 数据重复获取:多个组件都需要
currentUser,每个组件都要写useEffect去获取 - 缓存失效难题:用户在 A 页面更新了信息,B 页面的数据如何同步?
- 加载状态爆炸:每个 API 都要手动维护
isLoading、error、data三件套 - 数据新鲜度迷失:这份数据是 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} />;
}
问题诊断:
- 每个组件都要手动触发
fetchUser,容易重复请求 - 数据新鲜度无法保证(用户信息可能在服务器被修改了)
- 缓存逻辑需要手写(如何判断数据是否过期?)
- 跨页面共享困难(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>;
}
问题诊断:
- 双重数据源:同一份数据既在 Query 缓存又在 Store,哪个是真相?
- 同步 Bug:如果 Query 数据更新但 useEffect 没触发怎么办?
- 无意义的复杂度:为什么不直接用
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);
}
问题诊断:
- React Query 是为异步数据设计的,不是通用状态管理器
- 浪费了 Query 的缓存、过期、重试等特性(UI 状态不需要这些)
- 语义混乱:侧边栏状态不是"查询"出来的
正确做法:
// 纯 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)),
}));
// 哪里还有用户数据?都要手动更新...
},
});
问题诊断:
- 手动同步容易遗漏(还有多少地方用了这个用户数据?)
- 数据不一致风险(服务器返回的数据可能和你想的不一样)
- 代码维护噩梦(每个 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,
};
好处:
- 避免 Key 拼写错误
- 方便批量 invalidate(如
invalidateQueries({ queryKey: USER_KEYS.all })) - 清晰的数据模型索引
最佳实践 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 数据,正确做法是:
- 封装自定义 Hook(推荐):
// 任何组件都可以直接调用
export function useCurrentUser() {
return useQuery({ queryKey: ['currentUser'], queryFn: fetchUser });
}
- 如果真的需要 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)解决了本质不同的问题。
本文提出的架构理念可以总结为:
核心原则
-
Server State ≠ Client State
- Server State:异步、需要缓存、会过期、可能被别人修改
- Client State:同步、瞬态、完全由客户端控制
-
工具专注于各自的问题域
- React Query:专注解决异步数据管理(缓存、去重、更新、同步)
- Zustand:专注解决跨组件的 UI 状态共享
-
Pull, Don't Push
- 组件主动"拉取"所需数据(
useQuery) - 而不是获取数据后"推送"到全局 Store
- 组件主动"拉取"所需数据(
-
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 直观展示缓存状态、请求时序
性能优化
- 自动后台刷新(用户无感知获取最新数据)
- 智能去重(多个组件同时请求只发一次)
- 按需加载(只有组件挂载时才查询数据)
最后的建议
-
不要一次性重构
- 新功能直接用 React Query
- 老代码逐步迁移(从最简单的查询开始)
-
不要教条主义
- 如果团队已经深度使用 Redux 且运转良好,不一定要迁移
- 但新项目强烈建议采用 React Query + Zustand
-
不要过度设计
- 小型项目甚至可以只用 React Query + Context
- Zustand 只在确实需要时引入
-
关注数据的本质,而不是工具
- 先判断数据类型(Server State vs Client State)
- 再选择合适的工具
扩展阅读
最后一句话:状态管理不应该是痛苦的,选对工具,架构自然清晰。
如果这篇文章对你有帮助,欢迎点赞收藏。如果你有不同看法或实践经验,也欢迎在评论区讨论。
本文示例代码基于 React 18 + TypeScript + TanStack Query v5 + Zustand v4