引言
几乎每个前端团队在新项目技术选型时,都会为"到底用不用 Redux"吵上半小时。有人搬出 Redux 的老规矩,有人说 Zustand 就够,还有人说"我们用 Context 不就行了"。
吵不出结果,是因为吵错了问题。"选哪个库"本身没有标准答案,答案取决于你在管理哪一种状态。本文把四种主流方案——React 内置(useState/useReducer/Context)、Redux Toolkit、Zustand、Jotai——放到同一张桌子上,用同一组 Todo 例子对比它们的 API 手感、样板代码、中间件生态和性能表现,最后收敛成一张选型决策表。
一、先分清你在管理哪种状态
大多数选型翻车,根源是把不同种类的状态塞进同一个工具里。状态至少要分成五类:
| 状态类型 | 典型例子 | 该用什么 |
|---|---|---|
| 服务器状态 | 用户列表、文章数据、缓存 | TanStack Query / SWR |
| 客户端状态 | 弹窗开关、购物车、当前筛选 | Zustand / Jotai / Redux |
| URL 状态 | 分页、排序、搜索词、tab | searchParams |
| 表单状态 | 输入值、校验、提交中 | React Hook Form |
| 本地 UI 状态 | 一个组件内的 toggle | useState |
关键一句:服务器状态不要塞进客户端 store。如果在 Redux 里手动 fetch 用户列表、判断缓存失效、处理重试和竞态,就是在重复造 TanStack Query 的轮子。服务器状态有它自己的生命周期(loading / error / stale / refetch),和客户端状态是两码事。
本文只聚焦第二类——客户端状态——也就是 useState 管不过来、需要跨组件共享的那部分。下面的对比都围绕它展开。
二、React 内置方案:能不用库就别用
引入任何第三方库之前,先问一句:内置的够不够?
useState:组件内的局部状态
function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount((c) => c + 1)}>
count: {count}
</button>
)
}三行搞定,没有 provider、没有 action、没有 selector。凡是状态只活在一个组件里,这就是终点。
useReducer:转换逻辑变复杂时
当一个状态有多个动作、且转换逻辑互相耦合时,useReducer 比一堆 useState 更清晰。它把"状态怎么变"集中到一个纯函数里,天然方便测试。
interface Todo {
id: string
text: string
completed: boolean
}
type Action =
| { type: 'add'; text: string }
| { type: 'toggle'; id: string }
function reducer(state: Todo[], action: Action): Todo[] {
switch (action.type) {
case 'add':
return [
...state,
{ id: crypto.randomUUID(), text: action.text, completed: false },
]
case 'toggle':
return state.map((t) =>
t.id === action.id ? { ...t, completed: !t.completed } : t
)
}
}
function TodoApp() {
const [todos, dispatch] = useReducer(reducer, [])
return (
<div>
<button onClick={() => dispatch({ type: 'add', text: '新任务' })}>
添加
</button>
{todos.map((t) => (
<li key={t.id} onClick={() => dispatch({ type: 'toggle', id: t.id })}>
{t.completed ? <s>{t.text}</s> : t.text}
</li>
))}
</div>
)
}Context:跨层级共享,但要付渲染税
Context 解决的是 prop drilling(属性透传),而不是"全局状态管理"。它的硬伤是:Provider 的 value 一变,所有消费它的组件都会重渲染,不管它们用的是 value 里的哪一部分。
const ThemeContext = createContext<'light' | 'dark'>('light')
function App() {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
return (
<ThemeContext.Provider value={theme}>
<Header />
<Content />
<Footer />
</ThemeContext.Provider>
)
}
// 任何地方
function Header() {
const theme = useContext(ThemeContext)
return <header className={theme}>...</header>
}一个常见的坑:把 user、theme、notifications 全塞进一个 AppContext,结果任一值变化就整棵树重渲染。解法是拆分 Context,或者换用能按需订阅的库——Zustand 和 Jotai 正是为此而生。
内置方案的边界很清楚:状态只被一两个相邻组件共享 → useState/useReducer 足够;状态要跨整棵组件树共享且变化频繁 → 内置方案开始吃力。
三、Redux Toolkit:被误解的"老大哥"
Redux 的历史包袱很重。很多人记忆里它还是 2017 年的样子:手写 action type 常量、action creator、switch-case reducer、connect/mapStateToProps,一个计数器要拆四五个文件。
// 老 Redux:光一个计数器就这么多样板
const INCREMENT = 'counter/increment'
const increment = () => ({ type: INCREMENT })
function counterReducer(state = { value: 0 }, action) {
switch (action.type) {
case INCREMENT:
return { value: state.value + 1 }
default:
return state
}
}
// 还有 connect、mapStateToProps、mapDispatchToProps……但 Redux Toolkit(RTK)从 2019 年起已经把 80% 的样板干掉了。createSlice 一个文件搞定 reducer 和 action,configureStore 默认配好 DevTools 和 thunk。
import { createSlice, configureStore, PayloadAction } from '@reduxjs/toolkit'
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux'
interface Todo {
id: string
text: string
completed: boolean
}
const todosSlice = createSlice({
name: 'todos',
initialState: [] as Todo[],
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
state.push({ id: crypto.randomUUID(), text: action.payload, completed: false })
},
toggleTodo: (state, action: PayloadAction<string>) => {
const todo = state.find((t) => t.id === action.payload)
if (todo) todo.completed = !todo.completed
},
},
})
export const { addTodo, toggleTodo } = todosSlice.actions
export const store = configureStore({
reducer: { todos: todosSlice.reducer },
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector注意:reducer 里的 state.push、todo.completed = ... 看起来在"可变修改",其实是 Immer 在背后生成了新的不可变状态——这是 RTK 有意为之的写法,不影响纯函数语义。
function TodoList() {
const todos = useAppSelector((s) => s.todos)
const dispatch = useAppDispatch()
return (
<ul>
{todos.map((t) => (
<li key={t.id} onClick={() => dispatch(toggleTodo(t.id))}>
{t.completed ? <s>{t.text}</s> : t.text}
</li>
))}
</ul>
)
}RTK 真正的优势不在"少写代码"(这点 Zustand 更胜),而在三样东西:
- 强结构约定:所有状态变更都走 action,可预测、可追踪,团队大了也好 review。
- 中间件生态:thunk、RTK Query(直接内置服务器状态管理)、自定义 middleware、logger。
- 时间旅行调试:任何一次状态变更都能回放、回溯,对调试复杂交互几乎无可替代。
它适合:大型应用、多人协作、状态之间互相耦合、需要审计每一步变更的场景。代价是心智负担和样板仍比 Zustand 多。
四、Zustand:几乎零样板的瑞士军刀
Zustand 是目前"客户端状态"最轻的选择之一。一个 store 就是一个 hook,不需要 Provider 包裹整棵组件树,组件里按 selector 订阅自己关心的那一片。
import { create } from 'zustand'
interface Todo {
id: string
text: string
completed: boolean
}
interface TodoStore {
todos: Todo[]
addTodo: (text: string) => void
toggleTodo: (id: string) => void
}
export const useTodoStore = create<TodoStore>((set) => ({
todos: [],
addTodo: (text) =>
set((state) => ({
todos: [...state.todos, { id: crypto.randomUUID(), text, completed: false }],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t
),
})),
}))function TodoList() {
// 只订阅 todos,别的字段变化不会触发重渲染
const todos = useTodoStore((s) => s.todos)
const addTodo = useTodoStore((s) => s.addTodo)
return (
<ul>
{todos.map((t) => (
<li key={t.id}>{t.text}</li>
))}
</ul>
)
}几个关键点:
- 无 Provider:store 创建在模块顶层,任何组件直接
useTodoStore(...),不用改组件树。 - 按需订阅:
useTodoStore((s) => s.todos)只在todos变化时重渲染,粒度等价于 Jotai 的原子订阅。 - 动作在 store 内定义:
set拿到的state是新的,遵循不可变更新。 - 中间件可插拔:
persist(持久化)、devtools、immer(允许可变写法)一行接上。
异步动作也不需要 thunk 这类额外概念,直接 async/await,做完再 set:
interface UserStore {
user: User | null
loading: boolean
fetchUser: (id: string) => Promise<void>
}
export const useUserStore = create<UserStore>((set) => ({
user: null,
loading: false,
fetchUser: async (id) => {
set({ loading: true })
try {
const user = await fetch(`/api/users/${id}`).then((r) => r.json())
set({ user, loading: false })
} catch (e) {
set({ loading: false })
throw e // 显式抛出去,别静默吞掉
}
},
}))// 持久化到 localStorage + 接上 DevTools,只需包一层
export const useTodoStore = create<TodoStore>()(
persist(
(set) => ({ /* ...同上... */ }),
{ name: 'todos-storage' }
)
)它适合:中小型到大型应用的客户端状态,团队不想背 Redux 的心智负担,又需要一个干净、类型友好的全局 store。缺点是没有 Redux 那样强的"结构约定",状态组织完全靠团队自律。
五、Jotai:把状态拆成原子
Jotai 走的是另一条路:自底向上的原子模型。每个 atom 是一块独立的最小状态,派生状态用 get 从其它原子推导,组件只订阅它用到的那个原子——天然获得最细粒度的重渲染。
import { atom, useAtom } from 'jotai'
interface Todo {
id: string
text: string
completed: boolean
}
const todosAtom = atom<Todo[]>([])
const filterAtom = atom<'all' | 'active' | 'completed'>('all')
// 派生原子:由 todosAtom + filterAtom 推导,不复制状态
const filteredTodosAtom = atom((get) => {
const todos = get(todosAtom)
const filter = get(filterAtom)
if (filter === 'active') return todos.filter((t) => !t.completed)
if (filter === 'completed') return todos.filter((t) => t.completed)
return todos
})
const completedCountAtom = atom((get) =>
get(todosAtom).filter((t) => t.completed).length
)function CompletedCount() {
// 只订阅 completedCountAtom,todos 变化但已完成数不变时不会重渲染
const [count] = useAtom(completedCountAtom)
return <span>{count} 项已完成</span>
}
function TodoList() {
const [todos, setTodos] = useAtom(todosAtom)
const addTodo = () =>
setTodos((prev) => [
...prev,
{ id: crypto.randomUUID(), text: '新任务', completed: false },
])
return <ul>{todos.map((t) => <li key={t.id}>{t.text}</li>)}</ul>
}Jotai 的精髓是组合:todosAtom + filterAtom 推导出 filteredTodosAtom,派生逻辑复用而不复制状态。和 Zustand 的"一个大 store"不同,Jotai 鼓励把状态切成很多小原子。
它适合:状态之间是派生关系而非命令式变更关系的场景;需要极致细粒度重渲染、大量独立小块状态的场景(比如一个有很多独立开关的仪表盘)。代价:原子多了之后组织方式需要团队约定,atomFamily 等高级 API 有学习曲线。
六、样板代码对比:同一个 Todo,四种写法
把上面四种方案实现"新增一条 todo + 切换完成状态",并排看差距:
| 方案 | 需要的概念 | 样板代码量 | 类型标注负担 |
|---|---|---|---|
| useState/useReducer + Context | reducer + Provider + 2 个 hook | 中 | 中 |
| Redux Toolkit | slice + store + 2 个 typed hook | 中(概念多) | 中 |
| Zustand | 1 个 store | 最少 | 低 |
| Jotai | 若干 atom | 少 | 低 |
再放大一个维度:为一个"计数器"从零到可运行,各自要写多少行。
useState : 1 个组件,约 3 行
Context+reducer : Provider + reducer + useContext,约 20 行
Redux Toolkit : slice + store + Provider + selector,约 30 行
Zustand : 1 个 create,约 8 行,无 Provider
Jotai : 1 个 atom,约 4 行,无 Provider结论:局部状态,内置方案最短;全局状态,Zustand/Jotai 最短;Redux 的样板是"为可预测性付的税",不是废话。
七、中间件与 DevTools
调试能力是选型里最容易被低估的一环。
| 能力 | Redux Toolkit | Zustand | Jotai | React 内置 |
|---|---|---|---|---|
| 时间旅行调试 | ✅ 原生,最强 | ✅ 通过 devtools | ⚠️ 有限 | ❌ |
| 持久化 | ✅ 通过中间件 | ✅ persist 内置 | ⚠️ 社区方案 | ❌ 手动 |
| 异步副作用 | ✅ thunk / RTK Query | ✅ 动作内 async | ⚠️ 需手动 | ❌ 手动 |
| 日志中间件 | ✅ 生态成熟 | ✅ 简单 | ⚠️ 有限 | ❌ |
Redux 的时间旅行调试在复杂交互(拖拽排序、多步表单、乐观更新回滚)里价值极大——你能把状态"倒带"到任意一步看现场。Zustand 的 persist/devtools 中间件覆盖了 90% 的日常需求,配置还更简单。Jotai 和 React 内置则基本需要自己搭调试脚手架。
八、性能考量:谁重渲染、谁不重渲染
状态库的性能差异,本质是"一个状态变化,多少组件跟着重渲染"。
- Context:value 变化 → 所有消费组件重渲染,最粗粒度。拆分 Context 能缓解,但麻烦。
- Redux:selector 返回值变了才重渲染,配合
shallowEqual或 reselect 的createSelector能精细控制。 - Zustand:每个 selector 独立订阅,
useShallow解决对象 selector 的引用问题,粒度接近原子。 - Jotai:组件只订阅它读到的 atom,粒度最细,天生避免无关重渲染。
重渲染粒度(粗 → 细):
Context > Redux(需调 selector) > Zustand(selector 订阅) ≈ Jotai(原子订阅)实际工程里,除非状态变化极高频、组件树极深,前三者的差异在大多数业务中感知不到。先保证正确性和可维护性,性能优化到真出现卡顿时再动手——但 Jotai/Zustand 的细粒度是"免费的",从第一天起就不会引入整树重渲染。
九、选型决策表
把判断收敛成一张表,按场景对号入座:
| 你的情况 | 建议 |
|---|---|
| 状态只在 1-2 个相邻组件里 | useState / useReducer |
| 少量跨组件状态,变化不频繁 | Context(拆好粒度) |
| 中小型应用,想要零样板全局 store | Zustand |
| 状态是大量独立小块 / 强派生关系 | Jotai |
| 大型应用、多人协作、需要审计与时间旅行 | Redux Toolkit |
| 服务器数据(缓存 / 同步) | TanStack Query,别进客户端 store |
再补一条决策流程:
这状态是服务器数据吗?
├─ 是 → TanStack Query / SWR
└─ 否 → 只被一个组件用吗?
├─ 是 → useState
└─ 否 → 需要跨组件共享
├─ 状态简单、团队小 → Zustand
├─ 状态是大量独立小块 / 强派生 → Jotai
└─ 需要强约定 / 时间旅行 / 大团队 → Redux Toolkit十、四个常见误区
选型之外,还有几个坑几乎每个团队都踩过一遍。
误区一:把服务器状态塞进客户端 store。 从 Redux / Zustand 里手动 fetch、缓存、判失效、处理竞态和重试,等于自己实现半个 TanStack Query,还容易漏掉请求去重和缓存失效。守住那条线:服务器数据交给专门管服务器状态的库,客户端 store 只装界面状态。
误区二:所有状态都往全局塞。 一个只在模态框里用的 isOpen 也被提升成全局,结果全局 store 越来越臃肿、越来越难追踪。原则是状态下沉:状态放在离使用它的组件最近的地方,够不到了再提升。
误区三:用 Context 承载高频变化的状态。 theme 这种变化极少的适合 Context,但搜索输入、列表滚动位置这类高频状态放进 Context,会让整棵订阅树跟着高频重渲染。这类该交给 Zustand / Jotai。
误区四:因为"团队只会 Redux"就选 Redux。 这是一个真实的约束,但要清醒:你选的是"可预测性 + 生态 + 招聘惯性"的打包,而不是"最短代码"。如果项目不大、团队也愿意学,Zustand 的上手成本其实只有一天。
结语
状态管理选型没有标准答案,但有正确的提问顺序:先分状态类型,再挑工具。
- 内置方案优先,能用 useState/useReducer 解决就别上库。
- 客户端全局状态,默认考虑 Zustand——它把"少样板"和"细粒度"都占了。
- 状态天然是原子 / 派生关系时,Jotai 更顺手。
- 只有当你真的需要强约定、时间旅行调试和成熟中间件生态时,Redux Toolkit 才是那笔"值得付的税"。
最后记住:服务器状态和客户端状态是两条线,别让一个状态库同时干两份活。 选对工具之前,先选对问题。