React 状态管理:Redux Toolkit 与 Zustand / React State Management: Redux Toolkit and Zustand
📅 创建时间:2026-07-28 🏷️ 标签:#Redux #Zustand #StateManagement #RTKQuery #Jotai 📚 前置知识:[[02-hooks-state-and-effects]]
📋 本章目标
- 理解 Redux 单向数据流的完整链路:Action → Dispatch → Reducer → Store → Selector
- 掌握 Redux Toolkit 核心 API:createSlice (Immer 集成)、createAsyncThunk、RTK Query
- 掌握 Zustand 核心用法:create 函数、选择器与浅比较、常用中间件
- 理解 Jotai 原子化状态模型及其与 Zustand/Redux 的定位差异
- 建立"按状态生命周期选择方案"的决策框架
- 识别并避免状态管理的 6 类常见反模式
第1部分:状态分类 —— 在选库之前
1.1 状态管理的第一步不是选库
大多数团队的问题是:先选了一个库(通常是 Redux),然后把所有东西往里塞。正确的顺序是:先分类,再选方案。
┌─────────────────────────────────────────────────────────────┐
│ 状态四象限分类法 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 生命周期短 生命周期长 │
│ ┌──────────┬──────────┐ ┌──────────┬──────────┐ │
│ │ 弹窗开关 │ 表单草稿 │ │ 用户偏好 │ 编辑器 │ │
│ 组件 │ 展开折叠 │ 输入校验 │ │ 主题语言 │ 文档快照 │ │
│ 作用域│ 悬停态 │ 上传进度 │ │ 认证令牌 │ 多步骤 │ │
│ │ │ │ │ │ 向导 │ │
│ ├──────────┼──────────┤ ├──────────┼──────────┤ │
│ │ URL 参数 │ 列表数据 │ │ 购物车 │ WebSocket │ │
│ 跨组件│ 筛选条件 │ 详情缓存 │ │ 通知列表 │ 实时协作 │ │
│ 共享 │ 分页页码 │ 搜索结果 │ │ 全局消息 │ 状态 │ │
│ │ 排序字段 │ │ │ │ │ │
│ └──────────┴──────────┘ └──────────┴──────────┘ │
│ │
│ 原则:让状态离使用它的地方尽可能近 │
│ │
└─────────────────────────────────────────────────────────────┘1.2 六种状态类型与推荐归宿
| 类型 | 典型示例 | 推荐方案 | 判断依据 |
|---|---|---|---|
| 局部 UI 状态 | modal open、tab index、input value | useState | 仅一个组件关心 |
| 可分享导航状态 | filter、page、sort、search | URL searchParams | 需要前进/后退、可书签 |
| 表单工作状态 | draft values、validation errors | 表单库内部 (React Hook Form) | 高频变化、提交流 |
| 服务端缓存 | user list、order detail、product | TanStack Query / RTK Query / SWR | 真实数据在服务端 |
| 会话摘要 | currentUser、theme、locale | Context 或小型 Zustand store | 低频更新、全局消费 |
| 跨页面业务状态 | 编辑器文档、多步向导、工作台布局 | Zustand / Redux Toolkit | 复杂交互、多组件协作 |
第2部分:Redux 数据流 —— 单向循环详解
2.1 核心概念映射
Redux 的核心思想可以用一句话概括:整个应用的状态储存在单一 Store 中,改变状态的唯一方式是派发 Action,Reducer 根据 Action 计算新状态。
┌─────────────────────────────────────────────────────────────┐
│ Redux 单向数据流循环 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ dispatch(action) ┌────────────┐ │
│ │ │ ─────────────────────────→ │ │ │
│ │ View │ │ Store │ │
│ │ (React │ │ (单一状态 │ │
│ │ 组件) │ ←───────────────────────── │ 树) │ │
│ │ │ useSelector() 订阅 │ │ │
│ └──────────┘ └─────┬──────┘ │
│ │ │ │
│ │ 用户点击 / 生命周期 / 网络响应 │ │
│ ▼ ▼ │
│ ┌──────────┐ action + old state ┌────────────┐ │
│ │ Action │ ──────────────────────────→ │ Reducer │ │
│ │ Creator │ │ (纯函数) │ │
│ │ │ │ │ │
│ │ { type, │ │ (state, │ │
│ │ payload }│ │ action) │ │
│ └──────────┘ │ => newState│ │
│ └────────────┘ │
│ │
│ 关键约束: │
│ • Reducer 必须是纯函数 —— 相同输入必得相同输出 │
│ • State 不可直接修改 —— 必须返回新对象 │
│ • Action 是描述"发生了什么"的普通对象 │
│ │
└─────────────────────────────────────────────────────────────┘2.2 原始 Redux vs Redux Toolkit
原始 Redux 需要手写 action types、action creators、reducer switch-case,样板代码极多。Redux Toolkit (RTK) 通过 createSlice 一举解决:
| 问题 | 原始 Redux | Redux Toolkit |
|---|---|---|
| Action type 定义 | 手写字符串常量 const ADD = 'todos/add' | createSlice 自动生成 |
| Action creator | 手写函数 function add(text) { return { type: ADD, payload: text } } | 自动生成,同名导出 |
| Immutable 更新 | 手写展开运算符 [...state, newItem] | Immer 内置,直接 "mutate" |
| Reducer 结构 | switch-case,容易遗漏 default | 对象方法映射,类型安全 |
| Store 配置 | 手写 createStore + combineReducers + middleware | configureStore 一行搞定 |
2.3 createSlice 深度解析
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
// ── 1. 定义 State 类型 ──
interface EditorState {
documentId: string | null
content: string
isDirty: boolean
selectedBlockIds: string[]
undoStack: string[]
redoStack: string[]
}
// ── 2. 初始状态 ──
const initialState: EditorState = {
documentId: null,
content: '',
isDirty: false,
selectedBlockIds: [],
undoStack: [],
redoStack: [],
}
// ── 3. 创建 Slice ──
const editorSlice = createSlice({
name: 'editor', // 自动作为 action type 前缀 "editor/..."
initialState,
reducers: {
// Immer 让你直接"修改" state —— 实际生成不可变更新
documentOpened(state, action: PayloadAction<{ id: string; content: string }>) {
state.documentId = action.payload.id
state.content = action.payload.content
state.isDirty = false
state.undoStack = []
state.redoStack = []
},
contentChanged(state, action: PayloadAction<string>) {
state.undoStack.push(state.content) // "mutate" 但 Immer 处理为不可变
state.content = action.payload
state.isDirty = true
state.redoStack = []
},
blocksSelected(state, action: PayloadAction<string[]>) {
state.selectedBlockIds = action.payload
},
undo(state) {
const previous = state.undoStack.pop()
if (previous !== undefined) {
state.redoStack.push(state.content)
state.content = previous
}
},
redo(state) {
const next = state.redoStack.pop()
if (next !== undefined) {
state.undoStack.push(state.content)
state.content = next
}
},
documentSaved(state) {
state.isDirty = false
},
},
})
// ── 4. 自动生成的 Action Creators 和 Reducer ──
export const {
documentOpened,
contentChanged,
blocksSelected,
undo,
redo,
documentSaved,
} = editorSlice.actions
export default editorSlice.reducer2.4 configureStore 与 Selector
import { configureStore } from '@reduxjs/toolkit'
import { useSelector, useDispatch, TypedUseSelectorHook } from 'react-redux'
import editorReducer from './editorSlice'
// ── Store 配置 ──
export const store = configureStore({
reducer: {
editor: editorReducer,
// 其他 slice 在这里组合
},
// middleware 自动包含 redux-thunk + devtools
// 无需手动添加
})
// ── 类型导出(整个应用使用) ──
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
// ── 类型安全的 hooks ──
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector// ── 组件中使用 ──
function EditorToolbar() {
const dispatch = useAppDispatch()
const isDirty = useAppSelector(state => state.editor.isDirty)
const selectedCount = useAppSelector(state => state.editor.selectedBlockIds.length)
return (
<div className="toolbar">
<span>{selectedCount} blocks selected</span>
<button onClick={() => dispatch(undo())} disabled={/* ... */}>Undo</button>
<button onClick={() => dispatch(redo())} disabled={/* ... */}>Redo</button>
<button onClick={() => dispatch(documentSaved())} disabled={!isDirty}>
{isDirty ? 'Save*' : 'Saved'}
</button>
</div>
)
}2.5 createAsyncThunk —— 异步逻辑
当数据来自服务端时,优先考虑 RTK Query(见 2.6);但当异步结果需要直接写入 Redux state(如文件上传、WebSocket 消息),createAsyncThunk 是正确工具。
import { createAsyncThunk } from '@reduxjs/toolkit'
// ── Thunk 定义 ──
export const exportDocument = createAsyncThunk(
'editor/exportDocument',
async (format: 'pdf' | 'html', { getState, rejectWithValue }) => {
try {
const state = getState() as RootState
const response = await fetch('/api/export', {
method: 'POST',
body: JSON.stringify({
content: state.editor.content,
format,
}),
})
if (!response.ok) {
return rejectWithValue(await response.text())
}
return await response.blob()
} catch (err) {
return rejectWithValue(err instanceof Error ? err.message : 'Export failed')
}
}
)createAsyncThunk 自动生成三个 action:pending、fulfilled、rejected,可在 extraReducers 中响应:
const editorSlice = createSlice({
name: 'editor',
initialState,
reducers: { /* 同步 reducers */ },
extraReducers: (builder) => {
builder
.addCase(exportDocument.pending, (state) => {
// 可以加 exporting 标记
})
.addCase(exportDocument.fulfilled, (state) => {
state.isDirty = false
})
.addCase(exportDocument.rejected, (state, action) => {
console.error('Export failed:', action.payload)
})
},
})2.6 RTK Query —— 服务端缓存层
RTK Query 是 RTK 内置的数据获取与缓存方案,解决了手写 loading/error/data reducer 的所有痛点。
┌─────────────────────────────────────────────────────────────┐
│ RTK Query 缓存生命周期 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ 请求 ┌──────────┐ 缓存命中 │
│ │ 组件挂载 │ ─────────→ │ API 调用 │ ─────────→ 返回数据 │
│ │ useXxx │ │ │ │
│ └──────────┘ └──────────┘ │
│ ↑ │
│ │ 组件卸载 60s 后 │
│ │ │
│ ┌────┴─────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 缓存订阅计数 │ │ keepUnusedData│ │ refetchOnMount│ │
│ │ subscription │ │ For: 60s │ │ OrArgChange │ │
│ │ === 0 时启动 │ │ │ │ │ │
│ │ 垃圾回收倒计时 │ │ 计时结束 → 清理 │ │ 新参数 → 重新 │ │
│ └──────────────┘ └──────────────┘ │ 请求 │ │
│ └──────────────┘ │
│ │
│ Tag-based 失效(mutation 完成后自动重新获取): │
│ ┌──────────┐ invalidatesTags ┌──────────────────┐ │
│ │ POST │ ───────────────────→ │ GET /api/posts │ │
│ │ 创建文章 │ ['Posts'] │ 自动重新请求 │ │
│ └──────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
// ── API Slice 定义 ──
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post', 'User'],
endpoints: (builder) => ({
// Query: 读取数据(GET)
getPosts: builder.query<Post[], { page: number }>({
query: ({ page }) => `posts?page=${page}&limit=20`,
providesTags: (result) =>
result
? [...result.map(({ id }) => ({ type: 'Post' as const, id })), 'Post']
: ['Post'],
}),
getPost: builder.query<Post, string>({
query: (id) => `posts/${id}`,
providesTags: (_result, _error, id) => [{ type: 'Post', id }],
}),
// Mutation: 修改数据(POST/PUT/PATCH/DELETE)
createPost: builder.mutation<Post, Omit<Post, 'id'>>({
query: (body) => ({ url: 'posts', method: 'POST', body }),
invalidatesTags: ['Post'], // 创建后使 Post 列表缓存全部失效
}),
updatePost: builder.mutation<Post, Partial<Post> & { id: string }>({
query: ({ id, ...body }) => ({ url: `posts/${id}`, method: 'PATCH', body }),
invalidatesTags: (_result, _error, { id }) => [{ type: 'Post', id }],
}),
}),
})
export const {
useGetPostsQuery,
useGetPostQuery,
useCreatePostMutation,
useUpdatePostMutation,
} = api// ── 组件中使用 ──
function PostList() {
const [page, setPage] = useState(1)
const { data, isLoading, isError, error, isFetching } = useGetPostsQuery({ page })
if (isLoading) return <Skeleton />
if (isError) return <ErrorBanner message={error} />
return (
<div>
{isFetching && <ProgressBar />} {/* 后台重新获取时不阻塞 UI */}
{data?.map(post => <PostCard key={post.id} post={post} />)}
<Pagination page={page} onChange={setPage} />
</div>
)
}RTK Query 的核心价值:
- 零手写 reducer:loading、error、data、refetch 全部内置
- 智能缓存:
keepUnusedDataFor控制未使用数据的保留时间 - 自动去重:相同参数的并发请求自动合并为一次
- 标签失效:mutation 后通过
invalidatesTags精确控制哪些 query 需要重新获取 - 乐观更新:
onQueryStarted中先更新 UI 再等待服务器确认
第3部分:Redux 架构原则与边界
3.1 Slice 设计原则
┌─────────────────────────────────────────────────────────────┐
│ Slice 按业务能力拆分 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ❌ 按页面控件拆分: │
│ headerSlice、sidebarSlice、modalSlice、tableSlice │
│ → 控件只是 UI 呈现,不是业务概念 │
│ │
│ ✅ 按业务能力拆分: │
│ authSlice —— 认证、登录、权限 │
│ editorSlice —— 编辑器文档、选区、撤销 │
│ projectSlice —— 项目列表、当前项目、成员 │
│ notificationSlice —— 通知消息、已读状态 │
│ │
│ 原则:每个 Slice 对应一个"如果没有它,应用就少了 │
│ 一个功能领域"的业务概念 │
│ │
└─────────────────────────────────────────────────────────────┘3.2 Action 命名与 Selector 封装
// ── Action 命名:描述"发生了什么"(领域事件),而非"做什么"(setter) ──
// ❌ 命令式 setter 命名
setTitle, setContent, toggleSidebar, incrementCount
// ✅ 领域事件命名
documentTitleEdited, documentContentUpdated, sidebarToggledByUser,
itemAddedToCart, paymentSubmitted
// ── Selector:封装派生逻辑 ──
// ❌ 在组件中计算
function Component() {
const items = useAppSelector(s => s.cart.items)
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0) // 每次渲染都算
}
// ✅ 封装进 selector(可用 reselect 做记忆化)
import { createSelector } from '@reduxjs/toolkit'
const selectCartItems = (state: RootState) => state.cart.items
export const selectCartTotal = createSelector(selectCartItems, (items) =>
items.reduce((sum, i) => sum + i.price * i.qty, 0)
)
function Component() {
const total = useAppSelector(selectCartTotal) // items 不变就不重新计算
}3.3 Redux 边界清单
- State 保持可序列化:不要放入 DOM 节点、Promise、类实例、函数
- 异步远程数据优先 RTK Query,而不是手写 loading/error reducer
- Middleware 用于横切关注点(日志、埋点、崩溃报告),不放业务逻辑
- 不在 Redux 中缓存服务端数据(那是 RTK Query 的职责)
- DevTools 是架构审视工具,不只是调试工具——看 action 序列能发现设计问题
第4部分:Zustand —— 轻量级状态管理
4.1 核心理念
Zustand 的核心哲学:一个 store 就是一个 hook。没有 Provider、没有 context、没有 action type 字符串、没有 reducer switch-case。
┌─────────────────────────────────────────────────────────────┐
│ Zustand vs Redux 概念对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Redux Toolkit │ Zustand │
│ ──────────────────────────┼────────────────────────────── │
│ createSlice + configure │ create( ) — 一个函数搞定 │
│ Provider 包裹根组件 │ 无需 Provider │
│ dispatch(action) 触发 │ 直接调用 store 上的方法 │
│ useSelector 读取 │ useStore(selector) 读取 │
│ Immer 内置 │ Immer 通过中间件可选 │
│ Redux DevTools │ devtools 中间件 │
│ createAsyncThunk │ 直接写 async 函数 │
│ RTK Query │ 配合 TanStack Query 使用 │
│ │
│ 共同点:都基于"不可变状态 + 选择器订阅"模式 │
│ │
└─────────────────────────────────────────────────────────────┘4.2 create 函数基础
import { create } from 'zustand'
// ── 定义 Store 类型 ──
interface BearStore {
bears: number
fish: number
addBear: () => void
eatFish: () => void
reset: () => void
}
// ── 创建 store(就是一个 hook) ──
export const useBearStore = create<BearStore>((set) => ({
bears: 0,
fish: 100,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
eatFish: () => set((state) => ({
fish: state.fish - 1,
bears: state.bears + 1,
})),
reset: () => set({ bears: 0, fish: 100 }),
}))
// ── 组件使用:无需 Provider ──
function BearCounter() {
const bears = useBearStore((state) => state.bears)
return <h1>{bears} bears around here</h1>
}
function Controls() {
const addBear = useBearStore((state) => state.addBear)
const eatFish = useBearStore((state) => state.eatFish)
return (
<>
<button onClick={addBear}>Add bear</button>
<button onClick={eatFish}>Eat fish</button>
</>
)
}4.3 选择器与性能
Zustand 的渲染优化核心在于选择器的精确度。如果选择器返回的值和上次相同(通过 Object.is 或 shallow 比较),组件不重渲染。
// ── 选择器粒度与重渲染 ──
// ❌ 订阅整个 store —— 任何字段变化都会重渲染
const state = useBearStore() // 强烈不推荐
// ✅ 精确选择器 —— 只在 bears 变化时重渲染
const bears = useBearStore((state) => state.bears)
// ✅ 组合选择器 —— 浅比较(shallow equality)
import { shallow } from 'zustand/shallow'
const { bears, fish } = useBearStore(
(state) => ({ bears: state.bears, fish: state.fish }),
shallow // { bears: 0, fish: 100 } 与上次浅比较
)┌─────────────────────────────────────────────────────────────┐
│ 选择器重渲染决策流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 选择器返回新值 │
│ │ │
│ ▼ │
│ ┌─────────────────┐ 是 ┌─────────────────┐ │
│ │ 无 equalityFn? │ ──────→ │ Object.is 比较 │ │
│ └─────────────────┘ │ 相同?→ 不重渲染 │ │
│ │ 否 │ 不同?→ 重渲染 │ │
│ ▼ └─────────────────┘ │
│ ┌─────────────────┐ │
│ │ shallow? │ → 浅比较顶层属性 │
│ │ 相同?→ 不重渲染 │ │
│ │ 不同?→ 重渲染 │ │
│ └─────────────────┘ │
│ │ │
│ ┌───┴─────────────┐ │
│ │ 自定义比较函数 │ → 完全控制比较逻辑 │
│ │ (state, prev) │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘4.4 常用中间件
Zustand 的中间件通过 create 的管道组合,类似 Express/Koa 的模式。
import { create } from 'zustand'
import { persist, devtools, subscribeWithSelector } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
interface SettingsStore {
theme: 'light' | 'dark'
fontSize: number
language: string
setTheme: (theme: 'light' | 'dark') => void
setFontSize: (size: number) => void
}
export const useSettingsStore = create<SettingsStore>()(
// 中间件从内到外包裹:immer → persist → devtools → subscribeWithSelector
devtools(
persist(
subscribeWithSelector(
immer((set) => ({
theme: 'light',
fontSize: 16,
language: 'zh-CN',
setTheme: (theme) => set((state) => {
state.theme = theme // immer 中间件允许直接 mutate
}),
setFontSize: (fontSize) => set((state) => {
state.fontSize = fontSize
}),
}))
),
{
name: 'settings-storage', // localStorage key
partialize: (state) => ({ // 只持久化部分字段
theme: state.theme,
fontSize: state.fontSize,
language: state.language,
}),
version: 1, // Schema 版本
migrate: (persisted, version) => { // 迁移策略
if (version === 0) {
return { ...persisted as object, language: 'en' }
}
return persisted as typeof state
},
}
),
{ name: 'SettingsStore' } // DevTools 中的显示名
)
)中间件功能速查:
| 中间件 | 功能 | 使用场景 |
|---|---|---|
persist | 自动同步到 localStorage / sessionStorage / AsyncStorage | 主题、语言、用户偏好 |
devtools | 连接 Redux DevTools,支持 action 名称和时间旅行 | 开发和调试 |
immer | 允许在 set 中直接修改 state | 深层嵌套状态更新 |
subscribeWithSelector | 订阅 store 中特定字段的变化 | 外部系统、WebSocket 回调 |
4.5 异步操作
Zustand 不需要 createAsyncThunk。直接在 set 中写 async/await:
interface FishStore {
fish: { id: string; name: string }[]
loading: boolean
error: string | null
fetchFish: () => Promise<void>
}
export const useFishStore = create<FishStore>((set) => ({
fish: [],
loading: false,
error: null,
fetchFish: async () => {
set({ loading: true, error: null }) // 同步 set — 立即更新 loading
try {
const res = await fetch('/api/fish')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const fish = await res.json()
set({ fish, loading: false })
} catch (err) {
set({ error: (err as Error).message, loading: false })
}
},
}))
// 组件中
function FishList() {
const { fish, loading, error, fetchFish } = useFishStore()
// 无需 dispatch、无需 thunk、无需 extraReducers
}4.6 Store 外部访问与订阅
Redux 需要 store.getState() + store.subscribe()。Zustand 同样支持在 React 组件树之外访问状态:
// 在非 React 代码(WebSocket handler、axios interceptor 等)中
const currentFish = useFishStore.getState().fish // 获取当前值
useFishStore.setState({ loading: true }) // 直接设置
const unsub = useFishStore.subscribe(
(state) => state.error,
(error) => { if (error) showToast(error) }
) // 订阅特定字段第5部分:Jotai —— 原子化状态简介
5.1 原子模型 vs Store 模型
Jotai 从 Recoil 汲取灵感,采用自底向上的原子化状态模型。每个状态单元是一个独立的 atom,原子可以组合派生。
┌─────────────────────────────────────────────────────────────┐
│ Store 模型 vs 原子模型的架构差异 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Store 模型(Zustand / Redux) 原子模型(Jotai) │
│ ┌─────────────────────┐ ┌──┐ ┌──┐ ┌──┐ │
│ │ 单一 Store 树 │ │a1│ │a2│ │a3│ │
│ │ ┌────┬────┬────┐ │ └─┬┘ └─┬┘ └─┬┘ │
│ │ │auth│cart│edit│ │ │ │ │ │
│ │ └────┴────┴────┘ │ ▼ ▼ ▼ │
│ └─────────────────────┘ ┌────┬────┬────┐ │
│ 组件 A 订阅 auth │ 组件 A │ │
│ 组件 B 订阅 cart │(订阅 a1,│ │
│ 组件 C 订阅 edit │ a2) │ │
│ └─────────┘ │
│ 状态集中管理 │ 状态分散定义,按需组合 │
│ 适合全局状态 │ 适合组件级状态共享 │
│ 有明确的边界 │ 边界模糊,但极其灵活 │
│ │
└─────────────────────────────────────────────────────────────┘5.2 基本用法
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
// ── 基础原子 ──
const countAtom = atom(0)
const nameAtom = atom('World')
// ── 持久化原子 ──
const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light')
// ── 派生原子(computed / derived) ──
const doubleCountAtom = atom((get) => get(countAtom) * 2)
const greetingAtom = atom((get) => `Hello, ${get(nameAtom)}!`)
// ── 可写派生原子 ──
const celsiusAtom = atom(0)
const fahrenheitAtom = atom(
(get) => get(celsiusAtom) * 9 / 5 + 32, // getter
(_get, set, newF: number) => { // setter
set(celsiusAtom, (newF - 32) * 5 / 9)
}
)
// ── 组件使用 ──
function Counter() {
const [count, setCount] = useAtom(countAtom) // 读写
const double = useAtomValue(doubleCountAtom) // 只读
const setTheme = useSetAtom(themeAtom) // 只写
}5.3 Jotai 的定位
Jotai 在状态图谱中的独特位置:
| 场景 | Zustand | Jotai | 说明 |
|---|---|---|---|
| 全局配置(theme, locale) | 很适合 | 可以 | Zustand 更自然 |
| 复杂表单状态共享 | 可以 | 很适合 | Jotai 原子可以独立存在 |
| 编辑器文档 | 很适合 | 可以 | Zustand 的不可变更适合复杂状态 |
| 多个独立 UI 状态 | 需要放一个 store | 很适合 | 各原子互不干扰 |
| 派生计算 | 需要 selector | 天然支持 | Jotai 的 atom(get => ...) 自动追踪依赖 |
| 服务端缓存 | 配合 TanStack Query | 配合 TanStack Query | 两者都不是服务端缓存方案 |
一句话:Zustand 是"一个 store 管一件事",Jotai 是"一个 atom 管一个值"。
第6部分:状态方案选型决策树
6.1 决策框架
┌─────────────────────────────────────────────────────────────┐
│ 状态方案选型决策树 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ │
│ │ 状态的生命周期? │ │
│ └────────┬────────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ 单次渲染/事件 单次组件挂载 跨多个组件 │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ useRef │ │ useState │ │ 是否可放入URL? │ │
│ │ 或局部变量 │ │useReducer│ └──────┬───────┘ │
│ └──────────┘ └──────────┘ │ │
│ ┌──────┴──────┐ │
│ ▼ ▼ │
│ 可以 不可以 │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────────┐│
│ │ search │ │ 来自服务端? ││
│ │ Params │ └──────┬───────┘││
│ └──────────┘ │ ││
│ ┌───────┴───────┐││
│ ▼ ▼││
│ 是 否 ││
│ │ │ ││
│ ▼ ▼ ││
│ ┌────────────┐ ┌────────┐│
│ │ TanStack │ │复杂度? ││
│ │ Query / │ └───┬────┘│
│ │ RTK Query │ │ │
│ │ / SWR │ ┌──┴──┐ │
│ └────────────┘ ▼ ▼ │
│ 低复杂度 高复杂│
│ │ │ │
│ ▼ ▼ │
│ ┌────────┐┌─────┐│
│ │Zustand ││Redux│││
│ │/Jotai ││Tool-│││
│ │/Context││kit │││
│ └────────┘└─────┘│
│ │
└─────────────────────────────────────────────────────────────┘6.2 各方案的"甜点区"
// ── 场景1:组件自身使用 ──
function Accordion() {
const [open, setOpen] = useState(false)
// 不需要任何库
}
// ── 场景2:父子组件间传递,可放入 URL ──
function SearchPage() {
const [searchParams, setSearchParams] = useSearchParams()
const query = searchParams.get('q') ?? ''
const page = Number(searchParams.get('page') ?? '1')
// URL 是天然的可分享、可前进后退的状态容器
}
// ── 场景3:服务端数据 ──
function UserList() {
const { data } = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
// TanStack Query 处理 loading/error/cache/refetch/stale
}
// ── 场景4:低频跨层依赖 ──
const ThemeContext = createContext<'light' | 'dark'>('light')
// Context 适合主题、语言、用户信息等"全局常量级"状态
// ── 场景5:中等复杂度的跨组件状态 ──
const useCartStore = create<CartStore>((set) => ({ /* ... */ }))
// Zustand:轻量、无 boilerplate、选择器精确
// ── 场景6:复杂业务流程、团队协作、审计需求 ──
const store = configureStore({ reducer: { /* auth, editor, project, ... */ } })
// Redux Toolkit:强约束、DevTools、middleware、可预测6.3 Context 的正确使用边界
Context 最容易被误用为状态管理工具。它的设计初衷是依赖注入,不是高性能状态分发。
┌─────────────────────────────────────────────────────────────┐
│ Context 何时用 / 何时不用 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ✅ 适合 Context: │
│ • 低频更新的全局配置(主题、locale、功能开关) │
│ • 组件树的依赖注入(如 <DialogProvider>) │
│ • 值在应用生命周期内几乎不变 │
│ │
│ ❌ 不适合 Context(改用 Zustand / Jotai): │
│ • 高频更新的状态(每秒多次) │
│ • 状态使用者分散在组件树各处 │
│ • 某个深层子组件只需要状态的一个字段 │
│ → Context 值变化会导致所有消费者及其子树全部重渲染 │
│ │
│ 根本原因: │
│ Context 的重渲染机制是"值变化 → 所有消费者重渲染", │
│ 没有选择器订阅的粒度控制。Zustand/Redux 的选择器 │
│ 只在"组件关心的那部分状态变化"时才触发重渲染。 │
│ │
└─────────────────────────────────────────────────────────────┘// ── 反模式:用 Context 做高频状态 ──
const MouseContext = createContext({ x: 0, y: 0 })
function MouseProvider({ children }: { children: ReactNode }) {
const [pos, setPos] = useState({ x: 0, y: 0 })
useEffect(() => {
const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY })
window.addEventListener('mousemove', handler)
return () => window.removeEventListener('mousemove', handler)
}, [])
return <MouseContext.Provider value={pos}>{children}</MouseContext.Provider>
}
// 每次鼠标移动,所有消费者全部重渲染 —— 性能灾难
// ── 正确做法:用 Zustand ──
const useMouseStore = create<{ x: number; y: number }>(() => ({ x: 0, y: 0 }))
// 在 useEffect 中调用 useMouseStore.setState(...)
// 组件 useMouseStore(s => s.x) 只在 x 变化时重渲染第7部分:常见反模式与纠正
7.1 反模式 1:把所有数据放入 Redux
┌─────────────────────────────────────────────────────────────┐
│ 症状:Redux store 中有 formDraft、searchQuery、modalOpen、 │
│ ui.loading、page.scrollPosition ... │
│ │
│ 后果: │
│ • Store 膨胀,类型定义失控 │
│ • 组件卸载后状态残留,需要手动清理 │
│ • 序列化开销增大,DevTools 不可用 │
│ │
│ 纠正: │
│ • 回答"这个状态的 owner 是谁?生命周期多长?" │
│ • 表单 → 表单库内部,只提交最终结果 │
│ • 筛选/排序/分页 → URL searchParams │
│ • 弹窗/展开 → 组件内部 useState │
│ • 服务端数据 → RTK Query / TanStack Query │
│ • 只有真正的"跨页面业务状态"才进 Redux │
│ │
└─────────────────────────────────────────────────────────────┘7.2 反模式 2:在 Redux 中手写缓存服务端数据
// ── 反模式:手写 loading/error/data 三元组 ──
interface UsersState {
users: User[]
loading: boolean
error: string | null
}
const usersSlice = createSlice({
name: 'users',
initialState: { users: [], loading: false, error: null } as UsersState,
reducers: {
fetchUsersStart(state) { state.loading = true; state.error = null },
fetchUsersSuccess(state, action: PayloadAction<User[]>) {
state.users = action.payload; state.loading = false
},
fetchUsersFailure(state, action: PayloadAction<string>) {
state.error = action.payload; state.loading = false
},
},
})
// 问题:
// 1. 没有缓存过期 —— 用户切走再回来,数据是旧的吗?
// 2. 没有去重 —— 两个组件同时 mount,发两次请求
// 3. 没有后台刷新 —— 用户看到的是上次的数据还是刚刚拿的?
// 4. 没有乐观更新 —— mutation 完要等响应才更新 UI
// 5. 代码量巨大 —— 每个资源都要写一遍
// ── 纠正:用 RTK Query 或 TanStack Query ──
// 以上所有问题都由缓存层自动解决7.3 反模式 3:过度使用 Context 导致级联渲染
Context 值变化时,useContext 的所有组件都会重渲染——即使该组件只用了上下文的某个字段,且该字段并未变化。
// ── 反模式 ──
const AppStateContext = createContext({
user: null as User | null,
theme: 'light' as 'light' | 'dark',
sidebarOpen: false,
})
// 主题切换时,只关心 user 的组件也重渲染了
// ── 纠正方案 A:拆分 Context ──
const UserContext = createContext<User | null>(null)
const ThemeContext = createContext<'light' | 'dark'>('light')
const SidebarContext = createContext(false)
// ── 纠正方案 B:用 Zustand(更推荐) ──
// 因为拆分后的 Context 很容易陷入 Provider 嵌套地狱
const useUserStore = create<UserStore>(/* ... */)
const useThemeStore = create<ThemeStore>(/* ... */)7.4 反模式 4:在 Zustand 中订阅整个 Store
// ── 反模式 ──
function ExpensiveComponent() {
const store = useStore() // 订阅整个 store
return <div>{store.bears}</div> // 但只用了 bears
}
// 任何字段(fish, addBear, ...)变化都会触发重渲染
// ── 纠正 ──
function CheapComponent() {
const bears = useStore((s) => s.bears) // 精确订阅
return <div>{bears}</div>
}7.5 反模式 5:Store 中放入不可序列化的值
// ── 反模式 ──
interface EditorState {
domRef: React.RefObject<HTMLDivElement> // ❌ DOM 节点引用
websocket: WebSocket // ❌ 运行时对象
uploadPromise: Promise<string> // ❌ Promise
formatter: (text: string) => string // ❌ 函数
}
// 后果:
// • Redux DevTools 时间旅行失效
// • persist 中间件无法序列化
// • SSR hydration 不匹配
// • 很难测试
// ── 纠正:只存可序列化的数据 ──
interface EditorState {
documentId: string
content: string
isConnected: boolean // websocket 的连接状态
uploadStatus: 'idle' | 'uploading' | 'done' | 'error'
}
// WebSocket 实例、DOM ref、Promise 留在组件 ref 或外部模块中7.6 反模式 6:Next.js 中服务端请求间共享 Store
┌─────────────────────────────────────────────────────────────┐
│ Next.js SSR 中的 Store 安全 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 问题: │
│ ❌ 导出一个模块级单例 store │
│ export const store = create(...) │
│ → 所有请求共享同一个 store 实例 │
│ → 用户 A 的数据泄漏给用户 B │
│ │
│ 纠正: │
│ ✅ 使用 Store 工厂函数 │
│ const makeStore = () => configureStore({ reducer }) │
│ 每次请求调用 makeStore() 创建独立实例 │
│ │
│ ✅ 使用 next-redux-wrapper 或手动管理 │
│ https://github.com/kirill-konshin/next-redux-wrapper │
│ │
│ ✅ Zustand 同理: │
│ export const createBearStore = () => create(...) │
│ 在 React Server Component 边界不要访问 store │
│ │
└─────────────────────────────────────────────────────────────┘// ── 安全的 Next.js Store 工厂 ──
import { configureStore } from '@reduxjs/toolkit'
export const makeStore = () =>
configureStore({
reducer: { /* slices */ },
})
type AppStore = ReturnType<typeof makeStore>
type RootState = ReturnType<AppStore['getState']>
type AppDispatch = AppStore['dispatch']核心总结
总结 1:状态生命周期决定方案
局部 UI → useState
可分享导航 → URL
服务端缓存 → TanStack Query / RTK Query
低频全局 → Context
轻量跨组件 → Zustand
复杂业务流程 → Redux Toolkit总结 2:Redux 数据流
View → dispatch(action) → Store → Reducer(action, oldState) → newState
↓
View ← useSelector 订阅 ← Store ← 不可变新状态存入总结 3:RTK 三板斧
| API | 用途 | 关键特性 |
|---|---|---|
createSlice | 定义状态 + reducer | Immer 允许 "mutating" 语法 |
createAsyncThunk | 异步逻辑入 Store | 自动 pending/fulfilled/rejected |
createApi (RTK Query) | 服务端缓存 | 零手写 reducer,tag-based 失效 |
总结 4:Zustand 核心
一个 store = 一个 hook
无 Provider,无 action type,无 reducer switch-case
性能靠选择器 + shallow 比较
异步靠 async/await 直接写在 set 里
中间件管线:immer → persist → devtools → subscribeWithSelector总结 5:Jotai 定位
原子化模型,每个 atom 独立存在
派生 atom 自动追踪依赖
适合"多个独立的小状态需要按需组合"的场景
与 Zustand 互补而非替代总结 6:选型速查
| 问题 | 答案 |
|---|---|
| 只有一个组件用 | useState |
| 需要后退/书签 | URL searchParams |
| 来自服务端 | TanStack Query / RTK Query |
| 主题/语言/用户 | Context |
| 中等复杂度、轻量 | Zustand |
| 多 slice、审计、DevTools | Redux Toolkit |
| 许多独立小状态 | Jotai |
章节测试
测试 1:Redux 数据流
请按顺序写出 Redux 单向数据流中涉及的全部五个环节,并说明每个环节的职责。
测试 2:Immer 原理
Redux Toolkit 的 createSlice 允许在 reducer 中直接 state.count += 1,这是如何实现的?底层做了什么?
测试 3:RTK Query 缓存
RTK Query 中,providesTags 和 invalidatesTags 分别起什么作用?描述一个 POST mutation 创建新数据后自动刷新列表的完整流程。
测试 4:Zustand 选择器
以下代码有什么问题?如何纠正?
function BearCounter() {
const { bears } = useBearStore()
return <div>{bears}</div>
}测试 5:Context 边界
列出三种不适合用 React Context 管理状态的场景,并说明原因和替代方案。
测试 6:状态选型
一个应用中同时存在以下 6 种状态,请为每种选择最合适的方案并说明理由:
- 当前选中的 Tab 页签
- 用户搜索关键词和筛选条件
- 商品列表(来自 API)
- 当前登录用户信息
- 多步骤结账流程的状态
- 文件上传进度
测试 7:Next.js Store 安全
为什么在 Next.js 中不能直接导出模块级单例的 Zustand store 或 Redux store?应该怎么做?
参考答案
测试 1 答案
答案:Action Creator → Dispatch → Store → Reducer → Selector。
- Action Creator:创建描述"发生了什么"的 action 对象
- Dispatch:将 action 发送给 Store
- Store:持有当前 state,将 action 和 state 转发给 Reducer
- Reducer:纯函数
(state, action) => newState,根据 action 计算新状态 - Selector:从 Store 中提取组件需要的数据,决定是否重渲染
测试 2 答案
答案:Redux Toolkit 内部集成了 Immer 库。Immer 通过 ES6 Proxy 拦截对 state 的修改操作,将这些"可变"操作记录下来,最后基于原始 state 生成一个不可变的 新对象。所以开发者写的是 state.count += 1,但 Immer 实际生成的是 { ...state, count: state.count + 1 }。这使得 reducer 代码简洁但底层仍满足 Redux 的不可变要求。
测试 3 答案
答案:
- providesTags:query endpoint 声明"我提供这些标签的数据"。当标签被 invalidated 时,该 query 会重新获取。
- invalidatesTags:mutation endpoint 声明"我完成后使这些标签的数据失效"。
完整流程:
useCreatePostMutation调用 POST 请求创建新文章- 请求成功后,
invalidatesTags: ['Post']将'Post'标签标记为失效 - 所有
providesTags中包含'Post'的 query(如useGetPostsQuery)自动重新获取 - 列表组件自动显示包含新文章的最新数据
测试 4 答案
答案:const { bears } = useBearStore() 没有传选择器,订阅了整个 store。这意味着 store 中 任何 字段(fish、addBear 等)变化都会导致 BearCounter 重渲染。
纠正:使用精确选择器 — const bears = useBearStore((s) => s.bears)。这样只有 bears 字段变化时才重渲染。
测试 5 答案
答案:
- 高频更新的状态(如鼠标位置、动画帧):Context 值变化会导致所有消费者重渲染,高频更新会造成严重性能问题。替代方案:Zustand。
- 被深层子树中少数组件消费的大型状态:所有消费者都在同一个 Context 下,任何字段变化都触发全局重渲染。替代方案:Zustand + 选择器,或 Jotai 原子化。
- 需要选择器订阅细粒度更新的复杂状态:Context 没有内置选择器机制,开发者需要手动用
useMemo优化,容易出错。替代方案:Zustand / Redux Toolkit。
测试 6 答案
答案:
- Tab 页签 →
useState。只在一个组件或小组件内使用,不需要共享。 - 搜索/筛选 → URL searchParams。可分享、可书签、支持前进后退。
- 商品列表 → TanStack Query / RTK Query。服务端数据,需要缓存、去重、过期管理。
- 用户信息 → Context。低频更新、全局消费、值几乎不变。
- 结账流程 → Zustand。跨多个步骤/组件,中等复杂度,无需 Redux 的强约束。
- 上传进度 →
useState(在表单组件中)或 Zustand(如需跨组件展示进度条)。
测试 7 答案
答案:Node.js 服务端是长驻进程,多个请求共享同一个模块作用域。如果导出模块级单例 store,所有请求都会读写同一个 store 实例,导致用户 A 的数据泄漏给用户 B。这是严重的跨请求状态污染。
纠正:使用 Store 工厂模式——导出一个工厂函数(如 makeStore()),在每次请求/每次渲染时创建独立的 store 实例。Next.js 的 next-redux-wrapper 或 Zustand 的 createStore 工厂模式都能正确实现请求级隔离。
相关笔记
- [[02-hooks-state-and-effects]] - React Hooks、useState、useEffect、useReducer
- [[01-nextjs-app-router]] - Next.js App Router 架构与服务端渲染
- TanStack Query 官方文档:https://tanstack.com/query/latest
- Redux 风格指南:https://redux.js.org/style-guide
- Zustand 官方文档:https://zustand.docs.pmnd.rs/
下一步学习
- [ ] 阅读 [[01-function-calling]] — Agent 基础(如果学习路线涉及 AI)
- [ ] 阅读 TanStack Query 官方文档,完成至少一个项目实战
- [ ] 将现有项目中手写的 loading/error reducer 迁移到 RTK Query 或 TanStack Query
- [ ] 实践 Zustand 的
persist+devtools组合,完成一个带持久化的主题切换功能 - [ ] 对比 Zustand 和 Jotai 在实际项目中的使用体验,形成自己的选型直觉
学习状态:🟡 开始学习