引言
状态管理是前端架构中最容易被低估、也最容易演变成技术债的领域。useState 解决的是组件局部状态,Context 解决的是跨层级传递,但当共享状态开始跨越模块、页面甚至服务端边界时,方案的取舍就变得复杂。本文系统梳理从 React 内置方案到 Redux/Zustand/信号、再到 TanStack Query 的完整图谱,并给出可落地的选型决策树。文章中涉及的全部 API 均为真实存在的稳定 API,包括 React 18 的 useSyncExternalStore、Redux Toolkit 的 createSlice 与 configureStore、Zustand 的 create 与 persist middleware、TanStack Query 的 useQuery 等。
一、useState 与组件局部状态:从 Props 透传到状态提升
1.1 局部状态的最小单位
任何组件状态管理方案的起点都是组件内部的局部状态。React 的 useState 返回一个状态值和对应的更新函数,更新函数触发组件重新渲染:
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>当前计数:{count}</p>
<button onClick={() => setCount((c) => c + 1)}>加一</button>
</div>
);
}
当多个兄弟组件需要共享同一份状态时,React 官方的推荐做法是状态提升(Lifting State Up)——把状态移动到最近的公共父组件中,再通过 Props 传递。这一模式解决了基本问题,但在组件树层级较深时会引发 Props 逐层透传(Prop Drilling)的样板代码问题。
1.2 为什么局部状态会失控
局部状态只有在三个条件同时满足时才是正确的选择:状态只被当前组件及其直接子树消费、不需要跨模块共享、不需要持久化。一旦状态开始被多个无关组件读写,或需要在路由切换后保留,把状态锁死在组件内部就会产生数据不一致。
下表概括了不同作用域的状态应该使用哪种机制:
| 状态作用域 | 推荐机制 | 反模式 |
|---|---|---|
| 单个组件内部 | useState / useReducer | 全局 Store |
| 组件树内部共享 | Context + useReducer | 每个组件各存一份 |
| 跨页面/跨模块共享 | Zustand / Redux | 层层 Props 透传 |
| 服务端数据缓存 | TanStack Query / SWR | 塞进全局 Store |
二、Context 的局限与 useReducer 的补偿
2.1 Context 的渲染陷阱
Context 解决了 Props 透传,但它有一个被广泛误解的性能陷阱:当 Provider 的 value 变化时,所有消费该 Context 的组件都会重新渲染,无论它们是否只关心 value 中的一部分。经典的对策是把 value 拆成多个独立 Context,或用 useMemo 包裹 value:
import { createContext, useMemo, useState } from 'react';
export const ThemeContext = createContext({ theme: 'light', setTheme: () => {} });
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
// 没有 useMemo 时,每次渲染都会生成新对象,导致所有消费方重渲染
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
2.2 useReducer:集中变更逻辑
当组件内状态存在多条变更路径、且变更规则相互依赖时,useReducer 比多个 useState 更可维护。它把"状态如何变化"集中到 reducer 纯函数中,便于测试和调试:
import { useReducer } from 'react';
function reducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
case 'reset': return { count: 0 };
default: return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'reset' })}>重置</button>
</>
);
}
不过 useReducer 只解决"变更逻辑组织",不解决"跨组件共享"。Context + useReducer 的组合适合中大型组件树内的共享,但仍然无法规避整树重渲染问题。
三、Redux 生态:不可变更新与 DevTools
3.1 Redux Toolkit 的现代形态
Redux 是历史最悠久的全局状态方案之一。现代 Redux 实践以 Redux Toolkit(RTK) 为标准写法,它内置了 Immer 支持,让不可变更新可以写成可变的赋值语法:
import { createSlice, configureStore } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
addItem(state, action) {
// Immer 允许"看起来像可变"的写法,内部仍生成不可变状态
state.items.push(action.payload);
},
removeItem(state, action) {
state.items = state.items.filter((item) => item.id !== action.payload.id);
},
},
});
export const { addItem, removeItem } = cartSlice.actions;
export const store = configureStore({
reducer: { cart: cartSlice.reducer },
// 默认已启用 redux-devtools、redux-thunk,无需额外配置
});
3.2 中间件与持久化
Redux 的中间件机制让副作用处理、日志记录、持久化都可以插入到 dispatch 链路中。redux-persist 是常用的持久化方案,它把 store 的一部分白名单同步到 localStorage:
import { persistStore, persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import { combineReducers } from '@reduxjs/toolkit';
const rootReducer = combineReducers({ cart: cartSlice.reducer });
const persistedReducer = persistReducer(
{ key: 'root', storage, whitelist: ['cart'] },
rootReducer
);
export const store = configureStore({ reducer: persistedReducer });
export const persistor = persistStore(store);
Redux 的优势是可预测性与生态成熟:单一数据源、纯 reducer、时间旅行调试。代价是样板代码多、需要显式组织 action/reducer/selector,对于中小型项目往往过重。
四、原子状态:Zustand / Jotai / Recoil 对比
4.1 Zustand:最小化全局 Store
Zustand 提供一种极简的全局 Store 模型。它内部通过 useSyncExternalStore 连接 React 渲染系统,外部通过 getState() 可以在非 React 环境中读取,这在路由守卫、事件处理器中非常方便:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const useUserStore = create(
persist(
(set, get) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
isLoggedIn: () => get().user !== null,
}),
{ name: 'user-storage' } // 自动持久化到 localStorage
)
);
组件中可以用 selector 精确订阅,避免无关状态更新触发重渲染:
const username = useUserStore((state) => state.user?.name);
4.2 Jotai 与 Recoil:原子模型
Jotai 与 Recoil 都采用原子(Atom)模型:状态被拆分为最小单元,通过派生关系组合。Jotai 的 API 更轻量,无需 Provider 包裹即可使用(尽管生产环境仍推荐挂 Provider 以支持测试与持久化):
import { atom, useAtom } from 'jotai';
const countAtom = atom(0);
const doubleAtom = atom((get) => get(countAtom) * 2);
function Counter() {
const [count, setCount] = useAtom(countAtom);
const [double] = useAtom(doubleAtom);
return (
<div>
<span>{count} / 两倍:{double}</span>
<button onClick={() => setCount((c) => c + 1)}>+</button>
</div>
);
}
4.3 三方案对比矩阵
| 维度 | Zustand | Jotai | Recoil |
|---|---|---|---|
| 模型 | 单一 Store + selector | 原子 + 派生 | 原子 + selector |
| Provider 要求 | 不需要 | 可选 | 必须 |
| SSR 支持 | 内置 | 内置 | 需额外处理 |
| 中间件/持久化 | persist/devtools middleware | 社区插件 | 实验性 persist |
| 学习曲线 | 低 | 低 | 中 |
| React 内部机制 | useSyncExternalStore | useSyncExternalStore | 内部订阅系统 |
| 适用场景 | 中型全局状态 | 细粒度原子状态 | 大型复杂派生状态 |
三者都是真实且活跃的库。选择时不必迷信流行度:如果你的状态大多是"一份全局用户信息 + 少量标志位",Zustand 足够;如果状态之间存在大量互相派生的计算,Jotai/Recoil 的原子模型更契合。
五、信号(Signals):Preact Signals 与响应式范式回归
5.1 信号的基本用法
信号(Signals)是近年回归的热门范式。Solid.js 以信号作为核心,Preact 通过 @preact/signals 让 React 也能使用信号。信号的核心特点是值本身是响应式容器,读写时自动建立依赖图,因此更新可以精确到消费节点:
import { signal, computed, effect } from '@preact/signals';
const count = signal(0);
const double = computed(() => count.value * 2);
// 任何读取 count.value 的 effect 都会自动订阅
effect(() => {
console.log(`count 变化为:${count.value},双倍为:${double.value}`);
});
count.value += 1; // 自动触发上述 effect
在 React 组件中,信号通过 useSignals 或直接读取 .value(配合官方 React adapter)完成状态读取,读写时不会触发整棵组件树的重渲染——这是它与 Context 的本质区别。
5.2 信号与现有方案的互补关系
需要强调的是,信号并不是要取代 React 的渲染模型。React 官方对信号持开放态度,社区也出现了 useSyncExternalStore 作为连接信号与 React 的通用桥梁。信号更适合高频更新的共享值(如动画进度、鼠标位置、实时仪表盘数据),因为这些场景下每次更新触发全树 diff 的代价过高。
| 特征 | useState | 信号 | Context |
|---|---|---|---|
| 更新粒度 | 组件级 | 值级 | 消费组件级 |
| 细粒度订阅 | 需 useMemo/拆分 | 内置 | 不支持 |
| 组件外读取 | 不支持 | 支持 | 不支持 |
| 与 React 并发模式 | 兼容 | 需 adapter | 兼容 |
六、服务端状态:TanStack Query 与 SWR
6.1 服务端状态是另一类问题
前端状态可以粗分为两类:客户端状态(用户偏好、表单草稿)与服务端状态(来自 API 的数据)。服务端状态有缓存、失效、重试、竞态等复杂语义,把它塞进全局 Store 往往导致大量重复代码。TanStack Query 把服务端状态提升为一级概念,useQuery 负责数据获取、缓存、后台刷新:
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () =>
fetch(`/api/users/${userId}`).then((res) => res.json()),
staleTime: 5 * 60 * 1000, // 5 分钟内视为"新鲜",不重新请求
gcTime: 30 * 60 * 1000, // 缓存保留 30 分钟
});
if (isLoading) return <div>加载中…</div>;
if (error) return <div>出错了:{error.message}</div>;
return <div>{data.name}</div>;
}
6.2 写操作与失效
写操作通过 useMutation 表达,成功后可以用 invalidateQueries 让相关查询标记为过期并自动刷新:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function UpdateName({ userId }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (name) =>
fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user', userId] });
},
});
return <button onClick={() => mutation.mutate('新名字')}>改名</button>;
}
SWR 是同一赛道的轻量选择,核心理念是"stale-while-revalidate":先返回缓存数据,再后台重新验证。下表对比两个库的取舍:
| 特性 | TanStack Query | SWR |
|---|---|---|
| 缓存失效机制 | queryKey + invalidateQueries | mutate + revalidate |
| 依赖收集 | 显式 queryKey | 显式 key |
| 离线/重连恢复 | 支持 | 支持 |
| 数据偏平/无限加载 | useInfiniteQuery | useSWRInfinite |
| 与 UI 框架耦合 | React/Vue/Svelte 多端 | React 为主 |
服务端状态是全局 Store 的最大误用场景。团队经常把接口返回的列表复制进 Redux/Zustand,然后手写 loading/error/refresh 三件套——这些 TanStack Query 已经内置。实践中推荐的边界是:全局 Store 只放客户端共享状态,服务端数据一律交给查询缓存。
七、路由状态与 URL:把状态同步到浏览器地址栏
7.1 用 useSearchParams 表达页面状态
筛选条件、分页页码、搜索关键词这类状态天然适合放在 URL 中——刷新不丢失、可分享、可被浏览器前进后退。React Router 的 useSearchParams 让 URL 成为状态源:
import { useSearchParams } from 'react-router-dom';
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category') ?? 'all';
const page = Number(searchParams.get('page') ?? '1');
const setCategory = (next) => {
// 更新 URL 查询参数,触发对应状态变化
setSearchParams((prev) => ({ ...Object.fromEntries(prev), category: next, page: '1' }));
};
return (
<div>
<button onClick={() => setCategory('electronics')}>电子</button>
<p>当前分类:{category},页码:{page}</p>
</div>
);
}
7.2 URL 状态 vs 全局 Store
判断一个状态是否应该进 URL,可以问三个问题:刷新后需要保留吗?需要分享给其他人吗?浏览器前进后退需要支持吗?只要有一个为"是",就优先考虑 URL 状态。
| 判断维度 | 进 URL | 进全局 Store |
|---|---|---|
| 刷新保留 | 天然满足 | 需持久化中间件 |
| 可分享/书签 | 天然满足 | 不支持 |
| 前后退语义 | 天然满足 | 需手动同步 |
| 二进制/大对象 | 不适合 | 适合 |
八、状态持久化与调试
8.1 持久化的分层设计
持久化并非"把整个 store 存进 localStorage"这么简单。需要区分:哪些状态需要跨会话保留(登录态、主题、购物车)、哪些状态只需要跨刷新保留(表单草稿)、哪些状态禁止落盘(敏感信息)。Zustand 的 persist middleware 支持按 key 白名单:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const usePreferencesStore = create(
persist(
(set) => ({
theme: 'light',
locale: 'zh-CN',
setTheme: (theme) => set({ theme }),
setLocale: (locale) => set({ locale }),
}),
{
name: 'preferences',
partialize: (state) => ({ theme: state.theme, locale: state.locale }),
}
)
);
partialize 只持久化指定字段,避免把非序列化内容(函数、Date 实例)写入存储。
一条务实原则:持久化的是「用户可感知的偏好」,不是「全部应用状态」。登录态、主题、草稿值得落盘,临时 UI 标志位放进 sessionStorage 或干脆不持久化。
8.2 可调试性:DevTools 与时间旅行
全局状态方案的核心卖点之一是可调试性。Redux DevTools 提供时间旅行(time travel):逐步回放 action,观察每一步状态。Zustand 通过 devtools middleware 接入同一套 DevTools 协议:
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
export const useAppStore = create(
devtools((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
);
调试的另一个实用手段是状态快照:在 action 处理前后记录 JSON.stringify 的状态摘要,配合日志检索定位问题。很多团队把这做成一个全局 logger middleware,一劳永逸。
九、SSR 下的状态与 useSyncExternalStore
9.1 为什么 SSR 会破坏全局状态
服务端渲染时,每个请求都应该有独立的状态容器。如果全局 Store 是模块级单例,那么第一个请求写入的数据会被第二个请求读到——这就是经典的跨请求状态污染。正确做法是在请求周期内创建新的 store 实例:
// 不再导出单例,而是导出工厂函数
import { createStore } from 'zustand';
export const createAppStore = () =>
createStore((set) => ({
user: null,
setUser: (user) => set({ user }),
}));
在 Next.js 中,App Router 推荐使用 React 的 cache() 包裹工厂函数,确保单次请求内复用同一个 store 实例,同时避免请求间污染。
9.2 useSyncExternalStore:React 官方订阅原语
React 18 提供的 useSyncExternalStore 是连接外部数据源(Zustand、Redux、DOM API)与 React 渲染系统的官方桥。它要求数据源提供三个函数:subscribe、getSnapshot 和可选的 getServerSnapshot(用于 SSR 水合时避免客户端/服务端快照不一致):
import { useSyncExternalStore } from 'react';
const store = {
state: { count: 0 },
listeners: new Set(),
subscribe(listener) {
store.listeners.add(listener);
return () => store.listeners.delete(listener);
},
getSnapshot() {
return store.state;
},
emit() {
store.listeners.forEach((l) => l());
},
};
function CounterView() {
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
return <span>{snapshot.count}</span>;
}
getSnapshot 必须返回缓存一致的引用,否则 React 会判定"快照变化"而无限重渲染。这正是为什么第三方库内部都自行管理不变性——Zustand 在每次 set 时创建新对象,Redux 通过 Immer 保证不可变更新。理解 useSyncExternalStore,就理解了这些库如何与 React 的并发渲染安全共存。
9.3 选型决策树
综合全文,可以按以下顺序做选型判断:
| 问题 | 答案 | 选择 |
|---|---|---|
| 数据来自服务端 API? | 是 | TanStack Query / SWR |
| 只在单页内部组件树共享? | 是 | Context + useReducer |
| 需要刷新保留、可分享? | 是 | URL 状态(useSearchParams) |
| 需要跨模块全局共享? | 是 | Zustand(简单)/ Redux(复杂业务与规范) |
| 状态间大量派生计算? | 是 | Jotai / Recoil |
| 高频更新的共享值? | 是 | 信号(@preact/signals) |
选型的真正难点不是库与库之间的比较,而是先识别状态属于哪一类:客户端状态、服务端状态,还是 URL 状态。类别判断对了,工具选择自然水到渠成。
结语
状态管理没有银弹,只有"状态作用域 + 数据来源"两个维度的组合判断。局部状态用 useState/useReducer,组件树共享用 Context,跨模块全局共享交给 Zustand 或 Redux,原子化派生用 Jotai/Recoil,服务端数据必须交给 TanStack Query 类方案,而 URL 是常被忽视的"最可靠的持久化"。同时,useSyncExternalStore 作为 React 官方外部状态桥,值得每个前端工程师深入理解——它是连接第三方数据源与 React 并发渲染的基石。
在架构评审时,我建议团队为每个新状态写三行"状态声明":它从哪来(服务端/客户端/URL)、它的生命周期(单页/会话/永久)、谁消费它(单组件/多组件/全局)。回答完这三个问题,80% 的选型争议都会自然消解。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。