引言

几乎每个前端团队在新项目技术选型时,都会为"到底用不用 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:组件内的局部状态

tsx
function Counter() {
  const [count, setCount] = useState(0)

  return (
    <button onClick={() => setCount((c) => c + 1)}>
      count: {count}
    </button>
  )
}

三行搞定,没有 provider、没有 action、没有 selector。凡是状态只活在一个组件里,这就是终点。

useReducer:转换逻辑变复杂时

当一个状态有多个动作、且转换逻辑互相耦合时,useReducer 比一堆 useState 更清晰。它把"状态怎么变"集中到一个纯函数里,天然方便测试。

tsx
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 里的哪一部分。

tsx
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,一个计数器要拆四五个文件。

ts
// 老 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。

tsx
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 有意为之的写法,不影响纯函数语义。

tsx
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 更胜),而在三样东西:

  1. 强结构约定:所有状态变更都走 action,可预测、可追踪,团队大了也好 review。
  2. 中间件生态:thunk、RTK Query(直接内置服务器状态管理)、自定义 middleware、logger。
  3. 时间旅行调试:任何一次状态变更都能回放、回溯,对调试复杂交互几乎无可替代。

它适合:大型应用、多人协作、状态之间互相耦合、需要审计每一步变更的场景。代价是心智负担和样板仍比 Zustand 多。


四、Zustand:几乎零样板的瑞士军刀

Zustand 是目前"客户端状态"最轻的选择之一。一个 store 就是一个 hook,不需要 Provider 包裹整棵组件树,组件里按 selector 订阅自己关心的那一片。

tsx
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
      ),
    })),
}))
tsx
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>
  )
}

几个关键点:

  1. 无 Provider:store 创建在模块顶层,任何组件直接 useTodoStore(...),不用改组件树。
  2. 按需订阅:useTodoStore((s) => s.todos) 只在 todos 变化时重渲染,粒度等价于 Jotai 的原子订阅。
  3. 动作在 store 内定义:set 拿到的 state 是新的,遵循不可变更新。
  4. 中间件可插拔:persist(持久化)、devtools、immer(允许可变写法)一行接上。

异步动作也不需要 thunk 这类额外概念,直接 async/await,做完再 set:

ts
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 // 显式抛出去,别静默吞掉
    }
  },
}))
ts
// 持久化到 localStorage + 接上 DevTools,只需包一层
export const useTodoStore = create<TodoStore>()(
  persist(
    (set) => ({ /* ...同上... */ }),
    { name: 'todos-storage' }
  )
)

它适合:中小型到大型应用的客户端状态,团队不想背 Redux 的心智负担,又需要一个干净、类型友好的全局 store。缺点是没有 Redux 那样强的"结构约定",状态组织完全靠团队自律。


五、Jotai:把状态拆成原子

Jotai 走的是另一条路:自底向上的原子模型。每个 atom 是一块独立的最小状态,派生状态用 get 从其它原子推导,组件只订阅它用到的那个原子——天然获得最细粒度的重渲染。

tsx
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
)
tsx
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 少 低

再放大一个维度:为一个"计数器"从零到可运行,各自要写多少行。

code
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 内置则基本需要自己搭调试脚手架。


八、性能考量:谁重渲染、谁不重渲染

状态库的性能差异,本质是"一个状态变化,多少组件跟着重渲染"。

code
重渲染粒度(粗 → 细):
Context  >  Redux(需调 selector)  >  Zustand(selector 订阅)  ≈  Jotai(原子订阅)

实际工程里,除非状态变化极高频、组件树极深,前三者的差异在大多数业务中感知不到。先保证正确性和可维护性,性能优化到真出现卡顿时再动手——但 Jotai/Zustand 的细粒度是"免费的",从第一天起就不会引入整树重渲染。


九、选型决策表

把判断收敛成一张表,按场景对号入座:

你的情况 建议
状态只在 1-2 个相邻组件里 useState / useReducer
少量跨组件状态,变化不频繁 Context(拆好粒度)
中小型应用,想要零样板全局 store Zustand
状态是大量独立小块 / 强派生关系 Jotai
大型应用、多人协作、需要审计与时间旅行 Redux Toolkit
服务器数据(缓存 / 同步) TanStack Query,别进客户端 store

再补一条决策流程:

code
这状态是服务器数据吗?
  ├─ 是 → 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 的上手成本其实只有一天。


结语

状态管理选型没有标准答案,但有正确的提问顺序:先分状态类型,再挑工具。

最后记住:服务器状态和客户端状态是两条线,别让一个状态库同时干两份活。 选对工具之前,先选对问题。