React 状态管理实践:先分清"谁的 state",再谈要不要全局 store
【摘要】 React 状态管理实践:先分清"谁的 state",再谈要不要全局 store 前言"要不要上 Redux"是个伪命题。真正的问题是:这份状态属于谁、活多久、谁负责同步。分不清这一点,换十个库也治不好渲染问题和缓存不一致。 一、状态四分法类型典型例子归属推荐方案局部 UI 状态弹窗开关、表单输入组件自身useState / useReducer跨组件 UI 状态主题、侧边栏折叠、购物车应...
React 状态管理实践:先分清"谁的 state",再谈要不要全局 store
前言
"要不要上 Redux"是个伪命题。真正的问题是:这份状态属于谁、活多久、谁负责同步。分不清这一点,换十个库也治不好渲染问题和缓存不一致。
一、状态四分法
| 类型 | 典型例子 | 归属 | 推荐方案 |
|---|---|---|---|
| 局部 UI 状态 | 弹窗开关、表单输入 | 组件自身 | useState / useReducer |
| 跨组件 UI 状态 | 主题、侧边栏折叠、购物车 | 应用级 | Context(低频)/ Zustand(高频) |
| 服务端状态 | 列表、详情、用户信息 | 后端 | TanStack Query / SWR |
| URL 状态 | 筛选条件、分页、Tab | 浏览器 | searchParams |
这张表能解释大部分"状态管理很难"的错觉:80% 的难题是把服务端状态塞进了全局 store。一旦这么做,loading、error、请求去重、缓存失效、竞态覆盖,全都要自己手写。
二、服务端状态:别手写缓存
import { useQuery } from '@tanstack/react-query';
function UserList({ page }: { page: number }) {
const { data, isPending, error } = useQuery({
queryKey: ['users', page], // key 就是缓存身份
queryFn: ({ signal }) =>
fetch(`/api/users?page=${page}`, { signal }).then((r) => r.json()),
staleTime: 30_000, // 30s 内视为新鲜,不重复请求
});
if (isPending) return <Skeleton />;
if (error) return <ErrorBox error={error} />;
return <Table rows={data.list} />;
}
三个要点:
- 参数必须进 queryKey:
['users', page]换个 page 就是另一份缓存,这是自动去重的唯一依据; - 分清
staleTime和gcTime:前者决定"多久不重新请求",后者决定"没人用了多久后销毁缓存",两个概念混用会导致"数据莫名变空"; - 竞态交给库:
signal让旧请求在组件卸载或 key 变化时被取消,不用自己写let ignore = false。
写操作靠失效,而不是手动改缓存:
const mutation = useMutation({
mutationFn: updateUser,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
乐观更新则用 onMutate 先改缓存、onError 回滚、onSettled 重新校验,三步齐全才不会出现"界面显示了但服务端没存"。
三、客户端状态:Zustand 的边界
import { create } from 'zustand';
type CartState = {
items: CartItem[];
add: (item: CartItem) => void;
};
export const useCart = create<CartState>()((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
}));
// 选择器粒度 = 重渲染范围
const count = useCart((s) => s.items.length);
关键纪律:
- 永远带选择器,
useCart()直接拿整个对象等于订阅全量变更,任何字段变动都重渲; - 按业务域拆 store,别做"一个 AppStore 装下所有东西",那是把全局变量换了个名字;
- store 里不放服务端数据,需要聚合时用选择器派生,或直接读 Query 缓存。
四、Context 不是状态管理器
Context 只解决"跨层透传",不解决"更新效率"。Provider 的 value 一变,所有消费者都会重渲染,哪怕它只用到其中一项。
// ❌ 每次 render 都是新对象,所有消费者全量重渲
<ThemeContext.Provider value={{ theme, setTheme }}>
// ✅ 拆成两个 context,或对 value 做 memo
const value = useMemo(() => ({ theme, setTheme }), [theme]);
<ThemeContext.Provider value={value}>
适用边界很明确:低频变化(主题、语言、当前用户)。高频更新(输入、拖拽、动画)不要走 Context。
五、URL 也是状态源
筛选条件、分页、当前 Tab 放进 URL 带来三个白拿的好处:刷新可恢复、链接可分享、后退可回到上一组条件。
const [params, setParams] = useSearchParams();
const page = Number(params.get('page') ?? 1);
setParams({ page: '2' }, { replace: true }); // 别让每次筛选都进历史栈
判据:这个状态是否应该被"记住"或"分享"。是,就进 URL;不是,留在内存。
六、五个常见反模式
- 用一个巨型 store 存所有状态:更新任意字段,订阅者全部重渲;
useEffect里同步两个状态源:setState依赖另一个 state,几乎总是能用"单一数据源 + 派生"消灭;- 把 props 复制进 state:父级更新后子级不同步,直接实时计算;
useMemo滥用:基础类型比较的开销可能大于计算本身,只在依赖昂贵计算或引用稳定时用;- 跨模块直接读别人的 store:模块间耦合,重构时牵一发动全身,改用 props / 事件 / 接口层。
总结
决策顺序就一句话:能放组件内就不外提,能放 URL 就不进内存,服务端数据交给 Query,剩下的 UI 状态才考虑 Zustand,低频跨层用 Context。
状态管理的复杂度从来不是库造成的,而是"同一份数据有了两个主人"。让每份状态只有一个归属,代码自然就顺了。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)