React 状态管理选型:从 useState 到 Zustand
状态管理是 React 永恒的话题,选择太多反而乱。本文讲清状态管理的层级(useState/useReducer/context/Zustand/Redux 各管哪层)、本地状态 vs 服务端状态(React Query/SWR)、什么场景该上全局库、什么时候 context 就够,帮你跳出"什么都上 Redux"的误区。
本文要点
- 主题聚焦:Next.js、React
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
React 状态管理选型:从 useState 到 Zustand
每接手一个 React 老项目,你几乎都能看到同样的一幕:根组件套着一个巨大的 Redux Store,里面塞着主题色、用户信息、表单临时输入、列表数据、分页页码……然后所有人都抱怨它太重、太绕、改一个字段要动五个文件。
这不是 Redux 的错,是选型的错。状态管理从来不是"选最强的那个",而是"在合适的层级选最轻的那个"。这篇文章把 React 状态管理这件事讲透:状态分几层、每一层该用什么、什么时候该上库、什么时候 context 就够,以及为什么服务端状态根本不该进 Redux。
一、状态管理不是越强越好,按场景选最轻的
一个常见误区是:"项目大了就要上 Redux。" 这句话在 2018 年是对的,今天基本是错的。
原因很简单——React 自从 Hooks 出来以后,自带的状态能力已经非常强了。useState、useReducer、useContext,这三板斧能解决 80% 的状态需求。再加上 React Query / SWR 接管服务端状态,真正需要 Redux / Zustand 这类全局库的场景,被进一步压缩到了很窄的一段。
选型的核心原则只有一句:用能解决问题的最轻方案。 多余的抽象就是未来的债。一个 useState 能搞定的东西,你非要塞进 Redux,得到的不是"可维护性",而是"改一行代码要跳五个文件"。
所以正确的提问不是"用什么状态管理库",而是"这个状态属于哪一层"。把层分对了,库的选择基本就自动出来了。
二、状态分层:把状态放到正确的层级
我个人把 React 应用里的状态分成四层,从轻到重:
| 层级 | 归属 | 典型工具 | 例子 |
|---|---|---|---|
| 组件本地状态 | 单个组件内部 | useState / useReducer | 输入框值、折叠展开、loading 标志 |
| 跨组件共享状态 | 一棵子树内 | Context + useState | 主题、当前用户、i18n 语言 |
| 全局应用状态 | 整个 App | Zustand / Redux / Jotai | 购物车、多步表单聚合、复杂 UI 编排 |
| 服务端状态 | 后端数据缓存 | React Query / SWR | 接口列表、详情、用户资料 |
这四层之间有一个非常重要的判断:先看状态属于哪一层,再决定工具。 很多团队的问题不是工具选错了,而是根本没分清"这是本地状态还是服务端状态",把接口返回的列表数据塞进了 Redux,然后手写 loading / error / refetch / 分页 / 失效,最后把自己写成了一个低配版的 React Query。
下面每一层我们展开说。
三、useState/useReducer:够用就别加库
这是 90% 状态的归宿。一个状态如果只有当前组件(或它的直接子组件通过回调)会读写,那就老老实实待在组件里。
function SearchBox({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onSearch(query)}
/>
);
}
query 这种状态没有任何理由上 Context,更没有理由进 Redux。它只活在这一个组件里,组件卸载就销毁,干净利落。
当本地状态变复杂(多个字段联动、状态转换有规则)时,升级到 useReducer。 一个典型信号是:你写了三四个 useState,而且它们之间的更新有依赖关系,比如"打开弹窗时要重置表单、关闭时要清空错误"。这时候 useReducer 比一堆 useState 清晰得多:
type State = { step: number; data: FormData | null; error: string | null };
type Action =
| { type: 'NEXT'; payload: FormData }
| { type: 'RESET' }
| { type: 'ERROR'; payload: string };
function wizardReducer(state: State, action: Action): State {
switch (action.type) {
case 'NEXT':
return { ...state, step: state.step + 1, data: action.payload, error: null };
case 'RESET':
return { step: 0, data: null, error: null };
case 'ERROR':
return { ...state, error: action.payload };
default:
return state;
}
}
function MultiStepForm() {
const [state, dispatch] = useReducer(wizardReducer, { step: 0, data: null, error: null });
// ...
}
注意一个判断点:useReducer 的 state 如果只在本组件用,就不要再配 useContext。 很多人一见 reducer 就条件反射地套一个 Context Provider,把状态广播出去——结果就是每次 dispatch,整棵订阅子树都在重渲染。这就是典型的"过度设计"。
四、Context:跨组件共享,但别滥用
Context 解决的是 prop drilling(属性逐层透传)的问题。当某个状态需要在多层组件之间共享,但又不至于大到要全局管理时,Context 是最自然的解法。最经典的两个场景:主题(theme) 和 当前登录用户(user)。
const ThemeContext = createContext<{ theme: Theme; toggle: () => void } | null>(null);
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
const toggle = () => setTheme((t) => (t === 'light' ? 'dark' : 'light'));
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
Context 的甜区是低频变化的全局值——主题切换、语言切换、用户身份、特性开关。这些东西变化的频率很低,即使所有消费者在变化时一起重渲染,也几乎感受不到性能代价。
但 Context 有一个被反复忽视的陷阱:Context 的 value 每次变化,所有消费它的组件都会重渲染,无论你用到了 value 里的哪个字段。 这意味着如果你把一个高频变化的值(比如输入框的当前文本、鼠标位置、滚动条位置)塞进 Context,整棵订阅树会跟着疯狂重渲染,性能直接崩。
所以这里有一条硬规则:Context 不要承载高频变化的状态。 一旦你发现 Context 的 value 在每次用户交互时都在变,就该考虑两件事:
- 拆 Context:把高频字段和低频字段拆到两个 Context 里,消费者各取所需。
- 换 Zustand:用选择器订阅(selective subscription),只在自己关心的字段变化时才重渲染。
另外,Context 是就近共享的工具,不是"全局唯一"。一个 Provider 可以包在某个子树外面,只服务那一小块。不要一上来就把所有 Context 都堆在 App 根节点上——按作用域就近放,组件边界更清晰,也方便测试。
五、Zustand:多数项目的甜区
如果你的全局状态开始变复杂——比如一个购物车,既要存商品列表、又要算总价、又要支持撤销;或者一个多 Tab 编辑器,需要在多个不相邻的组件之间共享编辑状态——这时候 Context 会显得力不从心(主要因为性能和样板代码),Redux 又太重。Zustand 正好是中间那个甜区。
它的优点非常直接:
- API 极简:一个
create函数搞定,没有 action types、没有 reducers、没有 dispatch。 - 选择器订阅:组件可以只订阅自己关心的字段,其他字段变了不会触发重渲染。这是 Context 没有的能力。
- 不侵入组件树:不需要 Provider 包裹,任何组件直接
useStore()即可。
import { create } from 'zustand';
interface CartItem { id: string; name: string; price: number; qty: number; }
interface CartStore {
items: CartItem[];
add: (item: CartItem) => void;
remove: (id: string) => void;
clear: () => void;
total: () => number;
}
const useCart = create<CartStore>((set, get) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
remove: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
clear: () => set({ items: [] }),
total: () => get().items.reduce((sum, i) => sum + i.price * i.qty, 0),
}));
消费端的关键写法——只订阅你需要的字段:
// 只在 items 变化时重渲染
const items = useCart((s) => s.items);
const add = useCart((s) => s.add);
// 不要这样写,会让组件在任何状态变化时都重渲染
// const { items, add, remove, clear } = useCart();
这一行选择器就是 Zustand 比 Context 强的地方。在 Context 里,你很难做到"只有 items 变了我才更新"——你要么手写一套 memo + context 分割,要么直接认命全量重渲染。Zustand 把这件事做成了默认行为。
什么时候选 Zustand? 我的标准是:
- 状态需要在多个不相邻的组件之间共享(没法用 prop 透传,也不适合用一个 Context 圈起来)。
- 状态变化频率较高(比如购物车、富文本编辑器状态、复杂表单聚合)。
- 状态逻辑有一定的复杂度,但还没到"需要一个完整框架"的程度。
这三条同时满足,Zustand 基本就是答案。对绝大多数中型应用来说,Zustand + React Query 这对组合就足够撑起整个状态层了。
六、Redux:重,选它要有理由
Redux 不是不好,是绝大多数项目用不到它那么重。
Redux 的真正价值在两件事:
- 可预测的状态演化:action → reducer → new state 这条链路是单向且可追溯的,配合中间件(
redux-thunk、redux-saga)可以处理非常复杂的副作用编排。 - 时间旅行调试:DevTools 能让你回放每一次状态变更,定位问题极其高效。
但代价是:样板代码多、学习曲线陡、改动成本高。一个简单的"加一个字段"在 Zustand 里是改一处,在经典 Redux 里可能要动 action type、action creator、reducer 三处(虽然 Redux Toolkit 已经把这层样板大幅压缩了,但思维模型仍然是重的)。
所以 Redux 真正适合的是:超大型应用、多人团队协作、对状态可追溯性有严格要求(金融、医疗、复杂工作流)。这种场景下,Redux 的约束力反而是优势——它强制所有人按同一套范式写代码,避免 Zustand 那种"每个 store 写法都不一样"的随意性。
如果你不确定要不要 Redux,那基本就是不需要。用不准的工具比不用工具更糟,Redux 尤其如此。
七、服务端状态用 React Query/SWR,别塞进 Redux
这是近年来 React 社区最重要的认知升级之一:服务端状态和客户端状态是两种完全不同的东西,不该用同一个工具管。
服务端状态有几个客户端状态没有的特点:
- 它是异步的(loading / success / error 三态)。
- 它需要缓存(同一个接口在多个地方用到,不该请求多次)。
- 它会过期(数据可能被别人改了,你需要失效重取)。
- 它需要乐观更新和失败回滚。
- 它要处理分页、无限滚动、竞态。
你用 Redux 管这些,等于自己手写一个 React Query——而且大概率写得不如它好。React Query 把上面这些全做成了内置能力:
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 60_000, // 1 分钟内不重新请求
});
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage />;
return data.map((u) => <UserRow key={u.id} user={u} />);
}
变更(mutations)和自动失效也是声明式的:
const mutation = useMutation({
mutationFn: (newUser) => api.createUser(newUser),
onSuccess: () => {
// 创建成功后,自动让 users 列表失效并重新拉取
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
SWR 的定位类似,API 更精简,适合中小项目;React Query 功能更全(突变、依赖查询、并行查询、分页),适合复杂场景。两者选哪个都行,关键是别再自己手撸 loading / error / refetch 了。
把服务端状态交给 React Query 之后,你的 Redux / Zustand 就能瘦身一大半——它们只需要管真正的客户端状态(UI 状态、表单聚合、本地编辑中的草稿)。这才是它们本来该干的活。
八、选型决策树
把前面七节浓缩成一条可执行的决策链。新加一个状态时,按顺序问自己:
- 这是服务端数据吗?(来自接口、需要缓存/失效/重试)→ React Query / SWR,到此为止,不要进任何客户端状态库。
- 只有当前组件用吗? →
useState。多个相关字段联动就上useReducer。 - 需要在一棵子树内共享吗?(而且变化频率低)→ Context,Provider 就近包在子树外。
- 需要在多个不相邻组件之间共享、且变化频繁? → Zustand(默认推荐),除非团队已有 Redux 沉淀。
- 超大型应用、需要时间旅行调试、强约束团队协作? → Redux Toolkit。
注意这条决策链的顺序本身就是答案:从最轻的开始,只在它不够时才往上升级。 不要跳级。一个 useState 就能解决的问题,不要因为"将来可能要共享"就提前上 Zustand——YAGNI(You Aren't Gonna Need It)在状态管理里特别重要,过早抽象比晚抽象更难拆。
还有一个常被忽略的中间方案:状态提升(state lifting)。两个兄弟组件要共享状态?先把状态提到它们共同的父组件,用 prop + callback 传下去。只有当父组件层级太深、透传成本真的很高时,才考虑 Context 或外部 store。不要一见共享就跳到全局库。
小结
- 按层级选工具,不要按"哪个强"选。 状态有四层:本地、跨组件、全局、服务端,每一层都有它最合适的工具。
- 本地状态优先
useState/useReducer。 够用就别加库,reducer 不一定要配 Context。 - Context 适合低频全局值(theme、user、i18n),不要塞高频变化的状态,否则全树重渲染。
- Zustand 是多数项目的甜区——API 简单、选择器订阅性能好,中型应用的全局状态首选。
- Redux 留给超大型应用和强约束场景,大多数项目用它是过度工程。
- 服务端状态交给 React Query / SWR,缓存、失效、重试、loading 这些不该手写,Redux / Zustand 不该背这个锅。
金句:状态管理不是选最强的工具,而是选最轻的那个能解决问题的工具。 真正的高级工程师,不是把简单问题搞复杂,而是让复杂问题看起来简单。
Related