React 基础体系 · 第 46/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
Redux Toolkit 深入:Slice、Thunk、Listener、RTK Query 和测试
Redux Toolkit(RTK)是 Redux 官方维护的工具集。它并不是另一套独立的状态管理模型,而是在 Redux 的单向数据流、Reducer 和 Middleware 之上,提供了更安全的默认配置、更少的样板代码,以及面向异步数据请求的 RTK Query。
本文使用 TypeScript 说明一个完整的 Redux Toolkit 应用结构,重点覆盖:
- Store、Reducer、Action、Middleware 的关系
createSlice如何组织同步状态变化createAsyncThunk如何表达异步流程、错误和取消- Listener Middleware 如何响应 Action 或状态变化
- RTK Query 如何管理服务端数据、缓存、失效和请求生命周期
- Reducer、Thunk、Listener、RTK Query 和 React 组件如何测试
- 客户端、服务端渲染和 React Server Components 之间的边界
示例以现代 TypeScript、React 19 和当前主流 Redux Toolkit API 为基础。具体项目还需要根据使用的构建工具、路由框架和 SSR 方案调整入口代码。
一、先建立 Redux Toolkit 的运行模型
Redux 的核心状态转移可以写成:
其中:
- 是第 次更新前的状态;
- 是一个 Action,至少包含字符串类型字段
type; - 是 Reducer;
- 是更新后的状态。
Redux 的基本约束是:Reducer 应该是确定性的纯函数。对于同一个状态和同一个 Action,应得到相同的下一个状态,并且不应该在 Reducer 内执行网络请求、读取当前时间、修改外部变量或写入 localStorage。
完整的数据流如下:
flowchart LR
UI[React 组件] -->|dispatch(action)| D[Store.dispatch]
D --> MW[Middleware 链]
MW --> R[Root Reducer]
R --> S[新 State]
S --> SEL[Selector]
SEL --> UI
MW --> SIDE[异步请求/日志/副作用]
SIDE -->|dispatch 成功或失败 Action| D
configureStore 创建的 Store 至少包含以下部分:
- State:应用状态;
- Reducer:根据 Action 计算下一个 State;
- Dispatch:提交 Action;
- Middleware:在 Action 到达 Reducer 前后执行额外逻辑;
- Enhancer:扩展 Store 行为,通常由 Redux Toolkit 自动配置。
Redux Toolkit 的核心工具与这些概念的对应关系如下:
| 工具 | 主要职责 |
|---|---|
createSlice |
定义一组状态、同步 Reducer 和 Action Creator |
createAsyncThunk |
把一个异步函数转换成 pending/fulfilled/rejected Action 流程 |
createListenerMiddleware |
监听 Action 或状态变化,执行副作用 |
createApi |
管理服务端数据请求、缓存、标签失效和请求状态 |
configureStore |
创建带有安全默认 Middleware 的 Store |
一个重要区分是:
- Slice 通常描述客户端应用状态以及它的同步状态变化;
- Thunk 描述一次异步工作流;
- Listener 描述“发生某个 Action 后,还要做什么”;
- RTK Query 描述服务端数据的请求、缓存和同步;
- Reducer 本身不负责副作用。
二、配置类型安全的 Store
先创建一个最小的 Store。实际项目中可以按领域拆分多个 Slice。
// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit'
import cartReducer from '../features/cart/cartSlice'
import { api } from '../services/api'
import { listenerMiddleware } from './listener'
export const store = configureStore({
reducer: {
cart: cartReducer,
[api.reducerPath]: api.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware()
.concat(api.middleware)
.concat(listenerMiddleware.middleware),
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
api.reducerPath 默认是 "api",但不应该手写成固定字符串,因为 createApi 可以配置其他路径。使用计算属性可以保证 Reducer 键名与 API 实例一致。
api.middleware 必须加入 Store,否则 RTK Query 的缓存生命周期、轮询、失效和请求状态管理不会完整工作。
接着定义类型安全的 React Hooks:
// src/app/hooks.ts
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
withTypes 是现代 React Redux 提供的类型辅助方法。旧版本也可以使用泛型包装:
import { useDispatch, useSelector } from 'react-redux'
import type { TypedUseSelectorHook } from 'react-redux'
import type { AppDispatch, RootState } from './store'
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
组件只应使用 useAppDispatch 和 useAppSelector,而不是在每个文件中重新写类型。
为什么不能随意替换默认 Middleware
configureStore 默认加入了多种开发期检查,尤其是:
serializableCheck:检查 Action 和 State 是否包含不可序列化值;immutableCheck:开发期检查是否直接修改了 State;- thunk Middleware:让
dispatch能够接收函数形式的 Thunk。
以下值通常不适合放进 Redux State 或 Action:
PromiseMap、SetDate- DOM 节点
- 类实例
- 函数
AbortController
这不是说这些值在任何情况下都绝对不能使用,而是说 Redux 的时间旅行、持久化、调试和可重放能力依赖可序列化数据。若确实有第三方库 Action 携带不可序列化对象,应精确配置忽略路径,而不是整体关闭检查。
三、Slice:把状态、同步 Reducer 和 Action 组织在一起
3.1 Slice 的结构
createSlice 接收:
name:Action 类型前缀;initialState:初始状态;reducers:同步 Reducer;extraReducers:响应其他 Slice、Thunk 或 RTK Query Action。
示例定义一个购物车 Slice:
// src/features/cart/cartSlice.ts
import {
createSelector,
createSlice,
type PayloadAction,
} from '@reduxjs/toolkit'
import type { RootState } from '../../app/store'
export type CartItem = {
id: string
name: string
price: number
quantity: number
}
type CartState = {
items: CartItem[]
}
const initialState: CartState = {
items: [],
}
const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
itemAdded: (
state,
action: PayloadAction<Omit<CartItem, 'quantity'>>,
) => {
const existing = state.items.find(
(item) => item.id === action.payload.id,
)
if (existing) {
existing.quantity += 1
} else {
state.items.push({
...action.payload,
quantity: 1,
})
}
},
itemQuantityChanged: (
state,
action: PayloadAction<{ id: string; quantity: number }>,
) => {
const item = state.items.find((item) => item.id === action.payload.id)
if (!item) {
return
}
if (action.payload.quantity <= 0) {
state.items = state.items.filter((candidate) => candidate.id !== item.id)
} else {
item.quantity = action.payload.quantity
}
},
itemRemoved: (state, action: PayloadAction<string>) => {
state.items = state.items.filter((item) => item.id !== action.payload)
},
cartCleared: (state) => {
state.items = []
},
},
})
export const {
itemAdded,
itemQuantityChanged,
itemRemoved,
cartCleared,
} = cartSlice.actions
export default cartSlice.reducer
const selectCartState = (state: RootState) => state.cart
export const selectCartItems = createSelector(
[selectCartState],
(cart) => cart.items,
)
export const selectCartTotal = createSelector(
[selectCartItems],
(items) =>
items.reduce((total, item) => total + item.price * item.quantity, 0),
)
调用:
dispatch(
itemAdded({
id: 'keyboard',
name: 'Keyboard',
price: 299,
}),
)
生成的 Action 近似为:
{
type: 'cart/itemAdded',
payload: {
id: 'keyboard',
name: 'Keyboard',
price: 299
}
}
3.2 为什么 Reducer 中可以“直接修改”状态
示例中写了:
existing.quantity += 1
state.items.push(...)
这看起来违反了 Redux 的不可变更新规则,但 createSlice 内部使用 Immer。Reducer 实际接收的是一个 Draft,Immer 会记录这些写操作,然后生成新的不可变 State。
可以把过程理解为:
原始 State
↓
Immer 创建可追踪的 Draft
↓
Reducer 修改 Draft
↓
Immer 生成新的不可变 State
因此,“直接修改”只在 createSlice 或 createReducer 管理的 Reducer 内成立。以下代码仍然是错误的:
const addItem = (items: CartItem[], item: CartItem) => {
items.push(item)
return items
}
如果它在普通函数中接收真实 State,就会直接改变原对象。
另一个常见错误是同时修改 Draft 并返回另一个值:
badReducer: (state) => {
state.items = []
return { items: [{ id: 'unexpected' }] }
}
Immer 无法明确判断应该采用哪一种结果。一个 Reducer 应选择以下两种写法之一:
clearCart: (state) => {
state.items = []
}
或者:
replaceCart: (_state, action: PayloadAction<CartState>) => {
return action.payload
}
3.3 Selector 为什么不应直接散落在组件中
如果组件直接写:
const items = useAppSelector((state) => state.cart.items)
代码可以运行,但当 State 结构变化、需要派生数据或需要记忆化时,组件会承担过多细节。
Selector 的职责是从 State 读取数据,派生 Selector 则负责计算:
export const selectCartTotal = createSelector(
[selectCartItems],
(items) => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
)
createSelector 只有在输入 Selector 的结果引用没有变化时,才会复用上一次结果。它不是自动解决所有性能问题的工具:
- Reducer 每次都创建新的
items数组,派生结果会重新计算; - 如果输入 Selector 每次返回新数组,即使内容相同,也会导致重新计算;
- Selector 应保持纯函数,不应在其中请求网络或修改 State。
四、Thunk:把一次异步流程表示为多个 Action
4.1 Thunk 的本质
Thunk 是一个函数形式的 Action:
(dispatch, getState) => {
// 异步工作
}
普通 Reducer 只能处理对象 Action,而 Thunk Middleware 会先识别函数,再调用它。
createAsyncThunk 把一次异步操作标准化为三个阶段:
pending
↓
fulfilled 或 rejected
例如:
orders/submit/pending
orders/submit/fulfilled
orders/submit/rejected
Thunk 本身不直接修改 State。它通过派发这些 Action,让 Slice 的 extraReducers 更新状态。
4.2 一个带业务错误的 Thunk
假设服务端接口为:
POST /api/orders
请求体:{ items: CartItem[] }
成功:{ id: string; status: "created" }
失败:{ message: string; code: string }
定义类型和请求函数:
// src/features/orders/orderTypes.ts
import type { CartItem } from '../cart/cartSlice'
export type Order = {
id: string
status: 'created'
}
export type ApiError = {
code: string
message: string
}
export type SubmitOrderPayload = {
items: CartItem[]
}
export async function postOrder(
payload: SubmitOrderPayload,
signal?: AbortSignal,
): Promise<Order> {
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
signal,
})
const body: unknown = await response.json()
if (!response.ok) {
const error = body as Partial<ApiError>
throw {
code: error.code ?? 'UNKNOWN_ERROR',
message: error.message ?? '请求失败',
} satisfies ApiError
}
return body as Order
}
这里把 signal 传给 fetch,是为了让 Thunk 取消时可以中止底层请求。
定义 Thunk:
// src/features/orders/orderSlice.ts
import {
createAsyncThunk,
createSlice,
type PayloadAction,
} from '@reduxjs/toolkit'
import type { RootState } from '../../app/store'
import type { ApiError, Order, SubmitOrderPayload } from './orderTypes'
import { postOrder } from './orderTypes'
type OrderState = {
current: Order | null
status: 'idle' | 'pending' | 'succeeded' | 'failed'
error: ApiError | null
}
const initialState: OrderState = {
current: null,
status: 'idle',
error: null,
}
export const submitOrder = createAsyncThunk<
Order,
SubmitOrderPayload,
{
state: RootState
rejectValue: ApiError
}
>(
'orders/submit',
async (payload, thunkApi) => {
try {
return await postOrder(payload, thunkApi.signal)
} catch (error) {
if (isApiError(error)) {
return thunkApi.rejectWithValue(error)
}
throw error
}
},
{
condition: (_payload, { getState }) => {
const state = getState()
return state.orders.status !== 'pending'
},
},
)
function isApiError(value: unknown): value is ApiError {
if (!value || typeof value !== 'object') {
return false
}
const candidate = value as Partial<ApiError>
return (
typeof candidate.code === 'string' &&
typeof candidate.message === 'string'
)
}
const orderSlice = createSlice({
name: 'orders',
initialState,
reducers: {
orderReset: () => initialState,
},
extraReducers: (builder) => {
builder
.addCase(submitOrder.pending, (state) => {
state.status = 'pending'
state.error = null
})
.addCase(
submitOrder.fulfilled,
(state, action: PayloadAction<Order>) => {
state.status = 'succeeded'
state.current = action.payload
},
)
.addCase(submitOrder.rejected, (state, action) => {
state.status = 'failed'
if (action.payload) {
state.error = action.payload
} else {
state.error = {
code: 'UNEXPECTED_ERROR',
message: action.error.message ?? '未知错误',
}
}
})
},
})
export const { orderReset } = orderSlice.actions
export default orderSlice.reducer
Store 还需要注册:
import ordersReducer from '../features/orders/orderSlice'
export const store = configureStore({
reducer: {
cart: cartReducer,
orders: ordersReducer,
[api.reducerPath]: api.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware()
.concat(api.middleware)
.concat(listenerMiddleware.middleware),
})
4.3 rejectWithValue 与异常抛出的区别
Thunk 中有两种不同的失败:
return thunkApi.rejectWithValue(apiError)
表示“请求失败,但这是应用预期的业务错误”。此时:
action.payload
中会保留 ApiError。
而:
throw error
表示未预期异常,例如解析错误、代码错误或网络层异常。此时通常只有:
action.error
可用,且内容经过序列化处理,不应期待它保留任意自定义对象。
因此组件或上层流程应区分:
const resultAction = await dispatch(
submitOrder({
items,
}),
)
if (submitOrder.fulfilled.match(resultAction)) {
console.log('订单创建成功', resultAction.payload)
}
if (submitOrder.rejected.match(resultAction)) {
console.error('订单创建失败', resultAction.payload ?? resultAction.error)
}
更常见的写法是使用 unwrap():
try {
const order = await dispatch(
submitOrder({ items }),
).unwrap()
console.log(order.id)
} catch (error) {
console.error('提交失败', error)
}
unwrap() 的行为是:
- fulfilled:返回实际 Payload;
- rejected with
rejectWithValue:抛出action.payload; - rejected with普通异常:抛出序列化后的错误信息。
这使得组件可以使用普通的 try/catch,而不必手动检查 Action 类型。
4.4 condition、取消和竞态
示例中的 condition 在 Thunk 真正派发 pending 前执行:
condition: (_payload, { getState }) => {
return getState().orders.status !== 'pending'
}
当条件返回 false 时,这次执行会被跳过。它适合防止重复提交,但不是通用的请求去重机制,因为两个调用之间仍然可能存在复杂竞态,尤其是状态读取和外部请求已经开始时。
主动取消:
const promise = dispatch(submitOrder({ items }))
promise.abort()
try {
await promise.unwrap()
} catch (error) {
console.log('请求被取消或失败', error)
}
取消链路是:
promise.abort()
↓
Thunk 的 signal.aborted = true
↓
fetch 收到 AbortSignal
↓
底层请求被中止
↓
Thunk 派发 rejected
如果请求函数没有把 signal 传给 fetch 或其他支持取消的客户端,那么 Thunk 状态会变成取消,但底层网络请求可能仍然继续。
Thunk 还可能遇到响应顺序问题:
请求 A 开始
请求 B 开始
请求 B 返回,写入较新数据
请求 A 返回,覆盖了 B 的数据
condition 只能阻止部分请求,不能自动解决所有竞态。需要时应引入请求 ID、版本号,或使用 RTK Query 的缓存和请求管理能力。
五、Listener Middleware:对状态变化执行副作用
5.1 Listener 与 Thunk 的区别
Thunk 通常由调用方显式启动:
dispatch(submitOrder(payload))
Listener 则是预先注册规则:
当某个 Action 发生时,执行一个 effect
它们的适用模型不同:
- “点击提交按钮,然后请求订单”适合 Thunk 或 RTK Query mutation;
- “只要购物车变化,就保存到本地”适合 Listener;
- “登录成功后记录日志、刷新用户资料、跳转”可以用 Listener;
- “处理服务端数据缓存”应优先使用 RTK Query,而不是自己监听请求 Action。
5.2 注册 Listener
先创建实例:
// src/app/listener.ts
import {
createListenerMiddleware,
isAnyOf,
} from '@reduxjs/toolkit'
import {
cartCleared,
itemAdded,
itemQuantityChanged,
itemRemoved,
} from '../features/cart/cartSlice'
export const listenerMiddleware = createListenerMiddleware()
export const registerListeners = () => {
listenerMiddleware.startListening({
matcher: isAnyOf(
itemAdded,
itemQuantityChanged,
itemRemoved,
cartCleared,
),
effect: async (_action, listenerApi) => {
if (typeof window === 'undefined') {
return
}
const state = listenerApi.getState() as {
cart: { items: unknown[] }
}
window.localStorage.setItem(
'cart',
JSON.stringify(state.cart.items),
)
},
})
}
在客户端入口调用一次:
// src/main.tsx
import { registerListeners } from './app/listener'
import { store } from './app/store'
registerListeners()
// 将 store 传给 <Provider store={store}> ...
registerListeners 必须只调用一次。如果在 React 组件每次渲染时调用,会重复注册监听器,导致一个 Action 触发多次副作用。
更严格的类型写法可以在 Store 类型确定后创建类型化的 startListening:
const startAppListening =
listenerMiddleware.startListening.withTypes<RootState, AppDispatch>()
项目应确保所使用的 Redux Toolkit 版本支持该类型辅助 API;如果版本较旧,可以使用类型断言或手动定义 Listener 类型。
5.3 Action 匹配与状态比较
Listener 支持多种触发条件:
listenerMiddleware.startListening({
actionCreator: itemAdded,
effect: async (action, listenerApi) => {
console.log(action.payload.id)
},
})
也可以使用 predicate:
listenerMiddleware.startListening({
predicate: (_action, currentState, previousState) => {
return currentState.cart.items.length !== previousState.cart.items.length
},
effect: async (_action, listenerApi) => {
// 购物车商品数量变化后的副作用
},
})
predicate 接收:
- 当前 Action;
- 当前 State;
- 更新前的 State。
因此它可以表达“某个字段从未登录变成已登录”这类状态转换,而不仅是匹配某个 Action 类型。
5.4 取消和竞态
Listener 的 effect 可以异步执行,但多个 Action 可能产生多个并发 effect:
itemAdded A → effect A 开始
itemAdded B → effect B 开始
effect B 先结束
effect A 后结束
如果副作用是搜索、自动保存或统计上报,旧任务可能不应该覆盖新任务。可以使用取消相关 API 设计“只保留最新任务”的行为:
listenerMiddleware.startListening({
predicate: (action) => action.type === 'search/queryChanged',
effect: async (_action, listenerApi) => {
listenerApi.cancelActiveListeners()
await listenerApi.delay(300)
// 300ms 内再次触发同一个监听器时,
// 旧 effect 会被取消,新的 effect 继续执行。
// 此处可以 dispatch 搜索请求。
},
})
这里的关键是:
cancelActiveListeners()只影响当前监听规则已经启动的其他实例;delay等可取消流程会响应取消;- 如果你调用的外部库不支持取消,Listener 逻辑可能停止继续执行,但外部操作本身未必被中止。
Listener 不等于全功能工作流引擎。对于复杂的长生命周期任务、持久连接或需要严格事务语义的流程,应单独设计任务模型。
六、RTK Query:管理服务端数据,而不是普通客户端状态
6.1 服务端状态与客户端状态
客户端状态通常具有以下特征:
- 当前打开的弹窗;
- 表单输入;
- 购物车草稿;
- 当前筛选条件;
- UI 是否展开。
服务端状态通常具有不同特征:
- 数据由服务器拥有;
- 多个客户端可能同时修改;
- 数据需要请求、缓存、重新验证;
- 需要处理加载、错误、失效和重复请求。
RTK Query 专门解决第二类问题。它把请求结果放入一个规范化的缓存区域,并根据 endpoint、参数和标签管理生命周期。
它不是简单的:
useEffect(() => {
fetch('/api/posts').then(...)
}, [])
因为 RTK Query 还要处理:
- 请求状态;
- 相同参数的缓存键;
- 多个组件共享请求;
- 缓存订阅;
- 标签失效;
- mutation 后重新获取查询;
- polling 和重新连接;
- 请求错误。
6.2 定义 API Slice
// src/services/api.ts
import {
createApi,
fetchBaseQuery,
} from '@reduxjs/toolkit/query/react'
export type Post = {
id: string
title: string
body: string
}
export type NewPost = {
title: string
body: string
}
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: '/api',
}),
tagTypes: ['Post'],
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({
type: 'Post' as const,
id,
})),
{ type: 'Post' as const, id: 'LIST' },
]
: [{ type: 'Post' as const, id: 'LIST' }],
}),
getPost: builder.query<Post, string>({
query: (id) => `/posts/${id}`,
providesTags: (_result, _error, id) => [
{ type: 'Post', id },
],
}),
addPost: builder.mutation<Post, NewPost>({
query: (body) => ({
url: '/posts',
method: 'POST',
body,
}),
invalidatesTags: [{ type: 'Post', id: 'LIST' }],
}),
updatePost: builder.mutation<Post, Post>({
query: ({ id, ...body }) => ({
url: `/posts/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: (_result, _error, post) => [
{ type: 'Post', id: post.id },
{ type: 'Post', id: 'LIST' },
],
}),
}),
})
export const {
useGetPostsQuery,
useGetPostQuery,
useAddPostMutation,
useUpdatePostMutation,
} = api
createApi 通常应在应用中按数据域创建有限数量的 API 实例。每个 API 实例都会增加一组 Middleware 逻辑;把每个组件都定义成一个独立 API 实例,会使缓存失效和 Middleware 管理变得复杂。
6.3 Query 的生命周期
组件调用:
import { useGetPostsQuery } from '../../services/api'
export function PostList() {
const {
data: posts = [],
isLoading,
isFetching,
isError,
error,
refetch,
} = useGetPostsQuery()
if (isLoading) {
return <p>首次加载中……</p>
}
if (isError) {
return (
<div>
<p>加载失败:{JSON.stringify(error)}</p>
<button onClick={() => refetch()}>重试</button>
</div>
)
}
return (
<section aria-busy={isFetching}>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.body}</p>
</article>
))}
</section>
)
}
需要区分:
isLoading:当前没有缓存数据,正在进行首次加载;isFetching:正在请求,可能已经有旧数据;isError:本次查询失败;data:当前查询结果,可能是旧缓存或新响应。
因此,当已有旧数据并重新请求时,不应简单地用全屏 Loading 替换页面。更合适的表现通常是保留旧数据,同时用 isFetching 显示刷新状态。
查询缓存键近似由以下因素组成:
例如:
useGetPostQuery('p1')
useGetPostQuery('p1')
通常共享同一个缓存条目;而:
useGetPostQuery('p1')
useGetPostQuery('p2')
属于两个不同条目。
6.4 Mutation、标签和失效
Mutation 示例:
import { useState } from 'react'
import { useAddPostMutation } from '../../services/api'
export function AddPostForm() {
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [addPost, result] = useAddPostMutation()
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
try {
await addPost({ title, body }).unwrap()
setTitle('')
setBody('')
} catch (error) {
console.error('创建文章失败', error)
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="标题"
/>
<textarea
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder="正文"
/>
<button disabled={result.isLoading}>
{result.isLoading ? '提交中……' : '创建'}
</button>
{result.isError && <p>提交失败</p>}
</form>
)
}
invalidatesTags 的逻辑不是“直接修改所有缓存数据”,而是:
addPost 成功
↓
标记 Post/LIST 失效
↓
正在订阅 getPosts 的组件重新请求
↓
列表得到服务器最新数据
这里有两个重要边界:
- 只有仍被组件订阅的查询,才会按缓存生命周期重新获取;
- 标签是缓存关联机制,不是数据库事务,也不能保证服务器端操作一定成功。
如果希望创建成功后立刻把新数据写入缓存,可以使用 onQueryStarted 和 updateQueryData:
addPost: builder.mutation<Post, NewPost>({
query: (body) => ({
url: '/posts',
method: 'POST',
body,
}),
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
const patchResult = dispatch(
api.util.updateQueryData('getPosts', undefined, (draft) => {
draft.push({
id: 'temporary-id',
title: arg.title,
body: arg.body,
})
}),
)
try {
await queryFulfilled
} catch {
patchResult.undo()
}
},
invalidatesTags: [{ type: 'Post', id: 'LIST' }],
})
这个例子展示了乐观更新的风险:临时 ID 与服务器真实 ID 不同,服务端响应可能改变排序或字段。如果数据关系复杂,直接失效并重新请求往往比手动维护缓存更稳妥。
6.5 setupListeners 与重新获取
要启用诸如重新获得窗口焦点、重新连接网络等行为,需要在浏览器入口调用:
import { setupListeners } from '@reduxjs/toolkit/query'
import { store } from './app/store'
setupListeners(store.dispatch)
然后查询可以配置:
const result = useGetPostsQuery(undefined, {
refetchOnFocus: true,
refetchOnReconnect: true,
})
setupListeners 依赖浏览器事件。在服务端环境不应无条件调用,至少要确保代码只在客户端执行。
七、RTK Query 与 React 19、路由和服务端边界
RTK Query 的 React Hooks,例如:
useGetPostsQuery()
需要运行在客户端 React 组件中。它们依赖 React Hooks 和浏览器端订阅机制,不能直接当作服务端组件中的普通函数调用。
在使用支持服务端渲染或 React Server Components 的框架时,需要明确区分:
服务端组件 / loader / server action
↓
服务端直接访问数据源或服务端 API
↓
把初始数据传给客户端边界
客户端组件
↓
使用 RTK Query Hook 管理后续查询、缓存和刷新
React Router 也提供 loader、action 和导航状态等路由级数据能力。以下情况可以优先使用路由数据 API:
- 数据只服务于某个路由;
- 数据生命周期与路由进入、退出严格绑定;
- 需要在渲染前完成路由级加载;
- 表单提交本身属于路由 action。
以下情况更适合 RTK Query:
- 多个页面共享同一份服务端数据;
- 需要跨组件缓存;
- mutation 后需要按标签刷新多个查询;
- 需要 polling、重新聚焦刷新或缓存订阅;
- 数据并不只属于某一个 URL。
二者可以共存,但不要让同一个数据源同时被两个系统无规则地缓存。否则会出现:
Router loader 有一份数据
RTK Query 缓存有另一份数据
某一侧更新后,另一侧没有失效
在 SSR 中还要避免把 Store 创建成跨请求的全局单例:
// 服务端请求之间共享,这是危险的
export const store = configureStore(...)
如果服务端进程复用该对象,不同用户的状态可能互相污染。服务端通常应当:
每个 HTTP 请求创建一个新的 Store
↓
执行该请求需要的预取逻辑
↓
生成 HTML 和预加载状态
↓
客户端重新创建 Store 并进行 hydration
具体的脱水、注水和框架集成方式依赖所使用的 SSR 框架,不能用浏览器端的全局 Store 方案直接替代。
八、错误处理:不同错误必须走不同路径
8.1 Slice 和 Thunk 的错误
Thunk 常见的错误来源有三类:
- HTTP 状态码错误,例如 401、403、422、500;
- 网络错误,例如 DNS、断网或请求被取消;
- 客户端代码错误,例如 JSON 解析失败。
不要只把所有错误都写成:
state.error = '请求失败'
至少应保留可供 UI 和日志判断的结构:
type ApiError = {
code: string
message: string
fieldErrors?: Record<string, string>
}
服务端返回的错误必须经过校验。as ApiError 只是 TypeScript 编译期断言,不会在运行时验证数据。
8.2 RTK Query 的错误
fetchBaseQuery 返回的错误通常是一个联合结构,可能包含:
status为 HTTP 状态码;status为'FETCH_ERROR';status为'PARSING_ERROR';status为'TIMEOUT_ERROR';status为'CUSTOM_ERROR'。
因此 UI 不应假定:
error.message
永远存在。可以先记录完整对象:
console.error(result.error)
再根据项目 API 规范编写类型守卫或统一的错误映射函数。
8.3 401 刷新 Token 的边界
如果 API 使用 Token,常见做法是包装 fetchBaseQuery:
import {
fetchBaseQuery,
type BaseQueryFn,
type FetchArgs,
type FetchBaseQueryError,
} from '@reduxjs/toolkit/query'
const rawBaseQuery = fetchBaseQuery({
baseUrl: '/api',
})
const baseQueryWithReauth: BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
let result = await rawBaseQuery(args, api, extraOptions)
if (result.error?.status === 401) {
const refreshResult = await rawBaseQuery(
{
url: '/auth/refresh',
method: 'POST',
},
api,
extraOptions,
)
if (refreshResult.data) {
result = await rawBaseQuery(args, api, extraOptions)
} else {
api.dispatch({ type: 'auth/sessionExpired' })
}
}
return result
}
这段逻辑仍有并发问题:
请求 A 返回 401
请求 B 返回 401
A 和 B 同时刷新 Token
生产环境可能需要一个共享的刷新锁或队列,保证同一时间只执行一次刷新。这个问题不是 fetchBaseQuery 自动解决的。
此外,刷新 Token 的凭据应放在符合安全模型的位置。把长期有效 Token 放入可被 XSS 读取的 localStorage 会扩大风险;HttpOnly Cookie、CSRF 防护和服务端会话通常需要结合具体架构设计。
九、测试 Redux Toolkit 应该测试什么
测试不应只验证“某个函数被调用”,而应验证状态转移和外部行为。
推荐分层:
- Reducer 单元测试:输入 State 和 Action,验证输出 State;
- Thunk 测试:用真实 Store 和模拟 HTTP 服务验证 pending、fulfilled、rejected;
- Listener 测试:dispatch 触发 Action,验证副作用或后续 Action;
- RTK Query 测试:使用真实 API Slice,拦截 HTTP 请求,验证 Hook 或 Store 结果;
- 组件测试:验证用户操作、加载状态、错误状态和最终 UI。
不要在所有测试中 mock @reduxjs/toolkit。这样虽然容易让测试通过,却可能完全绕过真正的 Reducer、Middleware 和缓存行为。
以下示例使用 Vitest 和 MSW。测试环境需要具备:
npm install -D vitest msw jsdom
具体项目还需要根据构建工具配置 vitest 的 environment: 'jsdom'。
十、Reducer 测试:验证纯状态转移
// src/features/cart/cartSlice.test.ts
import { describe, expect, it } from 'vitest'
import reducer, {
cartCleared,
itemAdded,
itemQuantityChanged,
} from './cartSlice'
describe('cart reducer', () => {
it('添加新商品时数量为 1', () => {
const state = reducer(
undefined,
itemAdded({
id: 'keyboard',
name: 'Keyboard',
price: 299,
}),
)
expect(state.items).toEqual([
{
id: 'keyboard',
name: 'Keyboard',
price: 299,
quantity: 1,
},
])
})
it('添加已存在商品时只增加数量', () => {
const previousState = {
items: [
{
id: 'keyboard',
name: 'Keyboard',
price: 299,
quantity: 1,
},
],
}
const nextState = reducer(
previousState,
itemAdded({
id: 'keyboard',
name: 'Keyboard',
price: 299,
}),
)
expect(nextState.items[0].quantity).toBe(2)
expect(nextState.items).not.toBe(previousState.items)
})
it('数量改为 0 时移除商品', () => {
const state = reducer(
{
items: [
{
id: 'keyboard',
name: 'Keyboard',
price: 299,
quantity: 1,
},
],
},
itemQuantityChanged({
id: 'keyboard',
quantity: 0,
}),
)
expect(state.items).toEqual([])
})
it('清空购物车', () => {
const state = reducer(
{
items: [
{
id: 'keyboard',
name: 'Keyboard',
price: 299,
quantity: 1,
},
],
},
cartCleared(),
)
expect(state.items).toEqual([])
})
})
这类测试不需要 React,也不需要真实 Store。它验证的是最稳定的部分:给定输入后,Reducer 是否产生正确状态。
重点测试边界:
- 初始状态;
- 不存在的商品 ID;
- 数量为 0;
- 负数数量;
- 同一商品重复添加;
- Reducer 是否错误地修改了旧对象。
十一、Thunk 测试:使用真实 Store 和 MSW
11.1 设置 MSW
// tests/server.ts
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
export const server = setupServer(
http.post('/api/orders', async () => {
return HttpResponse.json({
id: 'order-1',
status: 'created',
})
}),
)
// tests/setup.ts
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from './server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
onUnhandledRequest: 'error' 很重要。它能防止测试悄悄发出未定义的请求,避免测试因为错误的 URL、方法或参数而失去意义。
11.2 创建测试 Store
测试 Store 应使用和生产 Store 相同的 Reducer、Thunk Middleware、Listener Middleware 和 RTK Query Middleware。可以封装一个工厂函数:
// tests/testStore.ts
import { configureStore } from '@reduxjs/toolkit'
import cartReducer from '../src/features/cart/cartSlice'
import ordersReducer from '../src/features/orders/orderSlice'
import { api } from '../src/services/api'
export function createTestStore() {
return configureStore({
reducer: {
cart: cartReducer,
orders: ordersReducer,
[api.reducerPath]: api.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware),
})
}
测试 Thunk:
// src/features/orders/orderSlice.test.ts
import { describe, expect, it } from 'vitest'
import { submitOrder } from './orderSlice'
import { createTestStore } from '../../../tests/testStore'
describe('submitOrder thunk', () => {
it('请求成功时保存订单', async () => {
const store = createTestStore()
const result = await store.dispatch(
submitOrder({
items: [
{
id: 'keyboard',
name: 'Keyboard',
price: 299,
quantity: 1,
},
],
}),
)
expect(submitOrder.fulfilled.match(result)).toBe(true)
expect(store.getState().orders).toMatchObject({
status: 'succeeded',
current: {
id: 'order-1',
status: 'created',
},
error: null,
})
})
})
测试失败路径:
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/server'
it('业务错误时保留 rejectWithValue 的 payload', async () => {
server.use(
http.post('/api/orders', () => {
return HttpResponse.json(
{
code: 'OUT_OF_STOCK',
message: '库存不足',
},
{ status: 409 },
)
}),
)
const store = createTestStore()
const result = await store.dispatch(
submitOrder({
items: [],
}),
)
expect(submitOrder.rejected.match(result)).toBe(true)
expect(result.payload).toEqual({
code: 'OUT_OF_STOCK',
message: '库存不足',
})
expect(store.getState().orders.status).toBe('failed')
})
这个测试证明了两个事实:
- HTTP 失败不会自动成为
rejectWithValue的 Payload; - 只有 Thunk 代码显式调用
rejectWithValue,业务错误才会出现在result.payload。
十二、Listener 测试:验证 Action 到副作用的连接
Listener 的测试重点不是测试 Redux Toolkit 内部实现,而是验证:
指定 Action
↓
Listener 被触发
↓
副作用发生
为了便于测试,可以把副作用抽成函数:
// src/features/cart/persistCart.ts
export function persistCart(items: unknown[]) {
if (typeof window !== 'undefined') {
window.localStorage.setItem('cart', JSON.stringify(items))
}
}
Listener:
// src/app/listener.ts
import {
createListenerMiddleware,
isAnyOf,
} from '@reduxjs/toolkit'
import {
cartCleared,
itemAdded,
itemQuantityChanged,
itemRemoved,
} from '../features/cart/cartSlice'
import { persistCart } from '../features/cart/persistCart'
export const listenerMiddleware = createListenerMiddleware()
listenerMiddleware.startListening({
matcher: isAnyOf(
itemAdded,
itemQuantityChanged,
itemRemoved,
cartCleared,
),
effect: async (_action, listenerApi) => {
const state = listenerApi.getState() as {
cart: { items: unknown[] }
}
persistCart(state.cart.items)
},
})
测试:
// src/app/listener.test.ts
import { describe, expect, it, vi } from 'vitest'
import { configureStore } from '@reduxjs/toolkit'
import cartReducer, {
itemAdded,
} from '../features/cart/cartSlice'
import { listenerMiddleware } from './listener'
describe('cart listener', () => {
it('购物车变化后持久化当前商品', () => {
const store = configureStore({
reducer: {
cart: cartReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
})
const setItem = vi.spyOn(Storage.prototype, 'setItem')
store.dispatch(
itemAdded({
id: 'keyboard',
name: 'Keyboard',
price: 299,
}),
)
expect(setItem).toHaveBeenCalledWith(
'cart',
JSON.stringify([
{
id: 'keyboard',
name: 'Keyboard',
price: 299,
quantity: 1,
},
]),
)
setItem.mockRestore()
})
})
如果测试中重用同一个全局 Listener 实例,应注意注册次数和测试之间的状态污染。更稳妥的做法是让 Listener Middleware 也由工厂函数创建,或者在测试环境中保证每个测试模块只注册一次。
十三、RTK Query 测试:验证缓存与请求行为
RTK Query 测试应尽量使用真实的 createApi 实例,而不是 mock Hook。
MSW Handler:
// tests/server.ts
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
export const server = setupServer(
http.get('/api/posts', () => {
return HttpResponse.json([
{
id: 'p1',
title: '第一篇文章',
body: '正文',
},
])
}),
)
如果测试 Store:
import { describe, expect, it } from 'vitest'
import { api } from '../services/api'
import { createTestStore } from '../../tests/testStore'
describe('RTK Query', () => {
it('查询成功后写入 API cache', async () => {
const store = createTestStore()
const result = await store.dispatch(
api.endpoints.getPosts.initiate(),
)
expect(result.data).toEqual([
{
id: 'p1',
title: '第一篇文章',
body: '正文',
},
])
const state = store.getState()
const queryState =
state[api.reducerPath].queries['getPosts(undefined)']
expect(queryState?.status).toBe('fulfilled')
result.unsubscribe()
})
})
这里的 result.unsubscribe() 用于释放该测试订阅。测试 RTK Query 时如果不清理订阅,可能出现:
- Jest 或 Vitest 进程无法退出;
- 后台重新获取仍然运行;
- 测试之间共享缓存;
- 定时器没有清理。
也可以测试标签失效:
it('mutation 成功后使列表查询失效', async () => {
const store = createTestStore()
const query = store.dispatch(
api.endpoints.getPosts.initiate(),
)
await query.unwrap()
const mutation = store.dispatch(
api.endpoints.addPost.initiate({
title: '新文章',
body: '新正文',
}),
)
await mutation.unwrap()
query.unsubscribe()
})
这个测试至少证明了请求可以完成,但若要严格验证“失效后重新请求”,应在 MSW Handler 中计数,并等待重新请求完成。测试网络请求次数时要考虑 RTK Query 的缓存状态、订阅状态和请求调度时机,不能仅依靠同步断言。
十四、组件测试:从用户行为验证完整链路
组件测试不应只 mock:
useGetPostsQuery: () => ({ data: [...] })
因为这样无法发现以下问题:
- Store 是否注册了
api.reducer; api.middleware是否缺失;- URL 是否写错;
- 查询参数是否错误;
- 错误状态是否正确展示;
- 组件是否在
isLoading和isFetching间做了错误判断。
组件测试通常应提供真实 Provider:
import { Provider } from 'react-redux'
import { render } from '@testing-library/react'
import { createTestStore } from './testStore'
export function renderWithStore(ui: React.ReactNode) {
const store = createTestStore()
return {
store,
...render(
<Provider store={store}>
{ui}
</Provider>,
),
}
}
然后使用 Testing Library 的用户行为 API:
const { findByText } = renderWithStore(<PostList />)
expect(await findByText('第一篇文章')).toBeInTheDocument()
这里使用异步查询是因为组件先经历:
初始渲染
↓
发起 GET /api/posts
↓
MSW 返回数据
↓
RTK Query 更新缓存
↓
组件重新渲染
如果直接使用同步的 getByText,测试可能在网络响应到达前就失败。
十五、常见误解与失败表现
15.1 把所有数据都放进一个 Slice
失败表现:
loading、error、缓存条目和业务实体混在一起;- 同一个服务端数据在多个页面重复保存;
- mutation 后需要手动更新大量 State;
- 请求取消和重新获取逻辑散落在组件中。
改进方向是先区分:
UI / 客户端状态 → Slice
单次异步业务流程 → Thunk
服务端查询与缓存 → RTK Query
跨 Action 副作用 → Listener
这不是强制规则,但可以减少职责重叠。
15.2 在 Reducer 中发请求
错误示例:
const slice = createSlice({
name: 'users',
initialState,
reducers: {
userLoaded: (state) => {
fetch('/api/user').then(...)
},
},
})
Reducer 可能被开发工具重放、测试直接调用或在不同环境执行。网络请求会使结果依赖外部时序,破坏 Reducer 的确定性。
请求应放入 Thunk、RTK Query、Listener 或路由层的数据加载机制。
15.3 误把 isLoading 当作所有 Loading
RTK Query 已经有缓存数据时,重新请求通常表现为:
isLoading === false
isFetching === true
data !== undefined
如果 UI 只判断 isLoading,用户可能看不到刷新状态;如果 UI 把 isFetching 当成首次加载并清空旧数据,页面会出现不必要的闪烁。
15.4 关闭所有序列化检查
错误处理方式:
getDefaultMiddleware({
serializableCheck: false,
immutableCheck: false,
})
这会隐藏真正的问题,例如:
- Action 携带
Error、FormData或函数; - Reducer 意外直接修改对象;
- 第三方库把不可序列化状态写入 Store。
如果某个已知路径确实必须忽略,应精确配置:
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['some/thirdPartyAction'],
ignoredPaths: ['someSlice.someField'],
},
})
忽略配置应与第三方库行为对应,并通过测试确认不会影响持久化和调试。
15.5 认为 RTK Query 会自动更新所有缓存
标签失效依赖三个条件:
- 查询声明了
providesTags; - mutation 声明了匹配的
invalidatesTags; - 目标查询仍在 Store 中并处于相关缓存生命周期。
如果标签类型或 ID 不匹配,mutation 可以成功,但列表不会重新获取。
例如:
getPost: providesTags: [{ type: 'Post', id: 'p1' }]
updatePost: invalidatesTags: [{ type: 'Article', id: 'p1' }]
这两个标签完全不同,不会互相失效。
15.6 在 React 组件中重复注册 Listener
错误示例:
function App() {
listenerMiddleware.startListening(...)
return <Page />
}
React 重新渲染后会再次注册,最终一个 Action 触发多个 effect。Listener 注册应放在应用初始化阶段,并控制初始化次数。
十六、如何诊断状态、异步和缓存问题
Store 中状态没有变化
依次检查:
dispatch的 Action 类型是否正确;- Slice Reducer 是否注册到
configureStore.reducer; - Reducer 是否错误地同时修改并返回;
- 组件是否从正确的 State 路径读取;
- Selector 是否因为输入引用不变而复用了旧结果。
Thunk 没有发请求
检查:
- Thunk 是否被
dispatch; condition是否返回了false;- 默认 Middleware 是否被替换而丢失 thunk 支持;
- 请求函数是否在进入
fetch前抛出异常; - 测试中的 MSW 是否拦截了正确的 URL 和 HTTP 方法。
RTK Query Hook 不工作
检查:
- 是否把
api.reducer注册到api.reducerPath; - 是否加入
api.middleware; - 是否在客户端 React 树中使用 Hook;
baseUrl是否符合当前浏览器和部署路径;- 查询参数是否导致了另一个缓存键;
- 是否在测试结束时取消订阅。
Listener 被调用多次
检查:
startListening是否只执行一次;- 是否重复创建 Store;
matcher是否同时匹配了多个监听规则;- effect 是否因为 Action 链再次 dispatch 自己会匹配的 Action;
- 是否存在多个 Provider 使用不同 Store。
Redux DevTools 可以查看 Action 顺序和 State 差异。一个有效的诊断方法是先回答:
这个问题是 Action 没有发出,
还是 Reducer 没有响应,
还是 State 更新了但 Selector 没读到,
还是请求成功了但缓存没有失效?
不要直接从组件 JSX 开始猜测。
十七、生产取舍:Thunk、Listener 和 RTK Query 如何分工
可以用下面的判断顺序选择工具:
状态是否由服务器拥有
如果是,并且需要请求、缓存或重新验证,优先考虑 RTK Query。
文章列表、用户详情、商品详情、分页结果
是否是一段有明确开始和结束的异步流程
如果是,Thunk 适合表达:
提交订单
导出文件
上传并解析
批量操作
Thunk 可以读取当前 State,也可以通过 rejectWithValue 传递结构化业务错误。
是否是对既有 Action 的响应
如果需求是:
登录成功后记录审计日志
购物车变化后持久化
用户退出后清理某些缓存
Listener 通常比在多个组件中手动调用副作用更集中。
是否只是本地同步状态
如果只涉及:
弹窗是否打开
当前 Tab
表单草稿
购物车客户端草稿
Slice 就足够,不需要为它创建 Thunk 或 RTK Query endpoint。
最终可以得到这样的分层:
flowchart TB
C[React 组件] -->|同步 UI Action| S[Slice]
C -->|一次异步业务操作| T[Thunk]
C -->|查询/新增/更新服务端数据| Q[RTK Query]
S --> ST[Redux State]
T --> ST
Q --> CACHE[RTK Query Cache]
ST --> L[Listener Middleware]
L --> FX[持久化/日志/跨模块副作用]
这些工具不是互斥的。例如:
- RTK Query mutation 成功后,Listener 可以记录审计事件;
- Thunk 可以读取购物车 Slice,然后调用服务端;
- Slice 可以响应 Thunk 的
pending、fulfilled和rejected; - Listener 可以 dispatch Slice Action 或 RTK Query 的工具 Action。
但应避免同一份服务端数据同时由 Thunk 手动存储、普通 Slice 维护、RTK Query 缓存和路由 loader 缓存,除非已经明确了同步策略。
十八、结语
Redux Toolkit 的核心价值不在于减少几行 Action Creator 代码,而在于把不同类型的工作放入相对明确的运行模型:
createSlice负责确定性的同步状态转移;- Immer 让 Reducer 可以用接近可变数据的写法生成不可变结果;
createAsyncThunk把异步流程拆成可观察、可测试的生命周期 Action;- Listener Middleware 根据 Action 或状态变化集中执行副作用;
- RTK Query 管理服务端数据的请求、缓存、订阅、失效和重新获取;
- 测试应从纯 Reducer 到真实 Middleware、网络拦截和组件行为逐层验证。
当出现状态不同步、请求重复、缓存未更新或副作用执行多次时,先沿着下面的链路定位:
用户行为
→ dispatch
→ Middleware
→ Reducer 或 RTK Query cache
→ Selector
→ React 重渲染
再结合客户端与服务端边界判断 Store 的生命周期,通常比在组件中继续添加条件分支更容易找到真正原因。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:TanStack Query:缓存键、失效、乐观更新、分页和离线
- 下一篇:Zustand 状态管理:Store、Selector、订阅、持久化和 SSR
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论