React 基础体系 · 第 12/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 状态管理选型:Redux Toolkit、Zustand、服务端缓存和边界
状态管理不是“选择一个全局变量库”,而是回答四个问题:
- 这份数据由谁拥有?
- 哪些组件需要读取或修改它?
- 它的生命周期是什么?
- 当网络请求、并发更新、刷新页面或服务端渲染发生时,谁负责保证一致性?
如果把所有数据都放进 Redux 或 Zustand,通常会得到一个“能运行但边界模糊”的应用:服务端数据被复制成客户端状态,缓存失效依赖手写逻辑,组件为了读取一个字段而订阅整个大对象,最后问题表现为重复请求、旧数据覆盖新数据、刷新丢失或 SSR 泄漏。
更可靠的选型方式,是先区分状态类型,再决定工具:
- 组件局部状态:某个组件或一小段子树独占的数据。
- 跨层 UI 状态:主题、弹窗、筛选条件、草稿等由多个组件共享的数据。
- 客户端领域状态:由客户端交互产生、需要事件记录和复杂更新规则的数据。
- 服务端状态:由后端拥有,客户端只是读取、缓存、刷新和提交的数据。
- URL 状态:搜索词、分页、排序、筛选条件等应该可分享、可回退、可恢复的数据。
- 持久化状态:需要跨刷新保留的数据,例如用户偏好,但不等于可以把所有状态写进
localStorage。
下图展示一种常见的数据流分层:
flowchart LR
UI[React 组件] --> Local[局部状态]
UI --> URL[URL 参数]
UI --> Store[客户端 Store]
UI --> Cache[服务端缓存]
Local --> UI
URL --> Router[路由/数据加载器]
Router --> Cache
Store --> API[请求函数]
API --> Server[服务端 API]
Cache --> Server
Server --> Cache
Cache --> UI
Store --> UI
关键点是:服务端缓存不是客户端 Store 的另一个名字。服务端缓存保存的是远端资源的快照和请求元数据;客户端 Store 保存的是客户端负责解释和修改的状态。两者可以协作,但不应默认合并。
一、先定义“状态”:拥有者决定工具
设一个状态值为 ,判断它应放在哪里,可以先问三个问题:
- :谁是它的权威拥有者?
- :它需要存活多久?
- :哪些组件需要订阅它?
如果后端数据库是权威来源,那么通常:
客户端保存的只是缓存:
而不是新的权威数据。缓存可能过期、被重新验证,甚至因为权限变化而失效。
例如,当前用户的订单列表通常由服务端拥有:
服务端数据库中的订单
↓
GET /api/orders
↓
客户端缓存:orders
↓
页面展示
如果用户创建订单:
POST /api/orders
↓
服务端写入成功
↓
重新获取 orders,或精确更新缓存
↓
页面展示新列表
把 orders 同时复制到 Redux、Zustand 和组件 useState,会产生三个副本:
serverOrders
reduxOrders
zustandOrders
componentOrders
此时每次更新都必须回答“谁先更新、谁可以覆盖谁、失败后回滚哪一份”。副本越多,一致性成本越高。
1.1 四类常见数据
局部交互状态
例如:
const [isOpen, setIsOpen] = useState(false);
const [draft, setDraft] = useState("");
这些状态只服务于当前组件或近邻组件,不应为了“统一管理”而放入全局 Store。
跨组件客户端状态
例如:
- 侧边栏是否展开;
- 当前编辑器的本地草稿;
- 多步骤表单的临时内容;
- 尚未提交到服务端的乐观操作队列;
- 客户端生成的通知已读标记。
这些状态通常由客户端拥有,适合 Context、Reducer、Zustand 或 Redux Toolkit,具体取决于复杂度和生命周期。
服务端状态
例如:
- 当前用户资料;
- 商品列表;
- 订单;
- 权限;
- 服务端生成的通知;
- 搜索接口结果。
它们通常需要处理:
- 加载中;
- 成功;
- 失败;
- 重试;
- 取消;
- 缓存;
- 重新验证;
- 并发请求;
- 写入后的失效或更新。
单纯的 useEffect + useState 可以实现这些逻辑,但随着资源数量增加,缓存键、竞态和失效规则会迅速重复。
URL 状态
分页、排序和搜索条件常常属于 URL:
/products?q=keyboard&page=2&sort=price
把它们放进组件状态会导致刷新丢失、复制链接无法复现页面、浏览器后退行为不符合预期。URL 本身是路由系统可持久化、可分享、可回退的状态容器。
二、Context 与 Reducer:解决跨层和状态转移,不等于完整缓存方案
React Context 解决的是“如何让后代组件访问一个值”,Reducer 解决的是“如何根据 action 计算下一状态”。
一个最小的编辑器状态可以这样写:
import {
createContext,
useContext,
useReducer,
type Dispatch,
type ReactNode,
} from "react";
type EditorState = {
title: string;
body: string;
saved: boolean;
};
type EditorAction =
| { type: "titleChanged"; value: string }
| { type: "bodyChanged"; value: string }
| { type: "saved" }
| { type: "reset"; state: EditorState };
function editorReducer(
state: EditorState,
action: EditorAction,
): EditorState {
switch (action.type) {
case "titleChanged":
return { ...state, title: action.value, saved: false };
case "bodyChanged":
return { ...state, body: action.value, saved: false };
case "saved":
return { ...state, saved: true };
case "reset":
return action.state;
default: {
const exhaustiveCheck: never = action;
return exhaustiveCheck;
}
}
}
const EditorStateContext = createContext<EditorState | null>(null);
const EditorDispatchContext =
createContext<Dispatch<EditorAction> | null>(null);
export function EditorProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(editorReducer, {
title: "",
body: "",
saved: true,
});
return (
<EditorStateContext value={state}>
<EditorDispatchContext value={dispatch}>
{children}
</EditorDispatchContext>
</EditorStateContext>
);
}
export function useEditorState() {
const value = useContext(EditorStateContext);
if (!value) {
throw new Error("useEditorState must be used inside EditorProvider");
}
return value;
}
export function useEditorDispatch() {
const value = useContext(EditorDispatchContext);
if (!value) {
throw new Error("useEditorDispatch must be used inside EditorProvider");
}
return value;
}
这里使用了 React 19 支持的 <Context value={...}> 写法。对于需要兼容更早 React 版本的代码,应使用 <EditorStateContext.Provider value={state}>。这属于写法差异,不改变 Context 的订阅语义。
Reducer 的状态转移可以形式化为:
其中:
- 是第 次更新前的状态;
- 是 action;
- 是纯函数;
- 是新状态。
例如:
s0 = { title: "", body: "", saved: true }
a1 = { type: "titleChanged", value: "Draft" }
R(s0, a1)
= { title: "Draft", body: "", saved: false }
Reducer 的价值在于:状态转移规则集中、可测试、可追踪。但它本身没有提供:
- 服务端请求缓存;
- 请求去重;
- stale 数据重新验证;
- 跨请求失效;
- 网络错误重试;
- 服务器写入后的缓存协调。
因此,Context + Reducer 适合跨层客户端状态,却不应被误当作服务端缓存工具。
2.1 Context 的更新边界
当 Provider 的 value 引用变化时,读取该 Context 的组件会重新渲染。下面的写法会让读取 ThemeContext 的组件在每次 count 变化时也收到新的对象:
<ThemeContext value={{ theme, setTheme, count }}>
{children}
</ThemeContext>
这不是 Context “比较字段后只更新必要组件”的保证。常见实现会根据 value 的引用变化传播更新,因此应把职责不同的值拆开:
<ThemeContext value={theme}>
<ThemeDispatchContext value={setTheme}>
{children}
</ThemeDispatchContext>
</ThemeContext>
Context 适合树状依赖注入,例如主题、国际化、当前编辑器实例。它不天然提供 Zustand 那样的字段选择器,也不提供 Redux 那样的 action 日志和中间件生态。
三、Redux Toolkit:复杂客户端领域状态的显式状态机
Redux 的核心约束是:
- Store 中有一棵状态树;
- 组件通过
dispatch(action)描述事件; - Reducer 根据旧状态和 action 计算新状态;
- Reducer 必须是纯函数;
- 状态更新通过不可变语义完成。
Redux Toolkit(RTK)是官方推荐的 Redux 使用方式。它通过 configureStore、createSlice、createAsyncThunk、RTK Query 等 API,减少手写不可变更新和配置的负担。
3.1 createSlice 的“可变写法”为什么仍然安全
import { configureStore, createSlice, PayloadAction } from "@reduxjs/toolkit";
type CartLine = {
productId: string;
quantity: number;
};
type CartState = {
lines: CartLine[];
};
const initialState: CartState = {
lines: [],
};
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
itemAdded(
state,
action: PayloadAction<{ productId: string }>,
) {
const line = state.lines.find(
(item) => item.productId === action.payload.productId,
);
if (line) {
line.quantity += 1;
} else {
state.lines.push({
productId: action.payload.productId,
quantity: 1,
});
}
},
itemRemoved(
state,
action: PayloadAction<{ productId: string }>,
) {
state.lines = state.lines.filter(
(item) => item.productId !== action.payload.productId,
);
},
},
});
export const { itemAdded, itemRemoved } = cartSlice.actions;
export const store = configureStore({
reducer: {
cart: cartSlice.reducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Reducer 中看起来在修改 state,但 RTK 默认使用 Immer。Immer 会记录这些修改,最终生成新的不可变状态,同时复用没有变化的结构。
概念上:
旧状态 s0
├── lines[0]
└── lines[1]
itemAdded
↓
新状态 s1
├── 新的 lines 数组
├── 未变化的 lines[0] 可复用
└── 被修改的 line 生成新结构
这降低了手写:
return {
...state,
lines: state.lines.map(...),
};
时的出错概率,但不改变 Redux 的核心要求:Reducer 仍然不能执行网络请求、读写 DOM 或依赖不稳定的外部副作用。
3.2 类型安全的 React 绑定
在现代 React Redux 项目中,应把 dispatch 和 selector 封装为类型安全的 hooks:
import {
useDispatch,
useSelector,
type TypedUseSelectorHook,
} from "react-redux";
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> =
useSelector;
组件中:
function AddToCartButton({ productId }: { productId: string }) {
const dispatch = useAppDispatch();
return (
<button
onClick={() => {
dispatch(itemAdded({ productId }));
}}
>
加入购物车
</button>
);
}
如果使用的 React Redux 版本没有 .withTypes,可以使用传统的类型封装方式。这里的 API 是否可用取决于 React Redux 版本,而不是 React 19 本身。
3.3 Redux 的订阅边界
下面的 selector 只读取购物车行:
const lines = useAppSelector((state) => state.cart.lines);
当其他 slice 更新时,组件不会因为 Redux 根状态对象变化而必然重新渲染;React Redux 会比较 selector 的返回值,默认使用严格相等比较。可是如果每次 selector 都返回新对象:
const summary = useAppSelector((state) => ({
count: state.cart.lines.length,
}));
summary 每次都是新引用,可能触发重新渲染。此时可以使用 memoized selector,或分别读取基础字段。是否需要优化,应以实际渲染和分析结果为依据,而不是机械地为每个 selector 加缓存。
3.4 createAsyncThunk 与服务端状态的边界
createAsyncThunk 适合表达一个异步领域事件,例如“提交结账”:
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
type CheckoutResult = {
orderId: string;
};
export const checkout = createAsyncThunk<
CheckoutResult,
{ cartId: string },
{ rejectValue: string }
>(
"checkout/submit",
async ({ cartId }, { rejectWithValue, signal }) => {
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ cartId }),
signal,
});
if (!response.ok) {
const message = await response.text();
return rejectWithValue(message || "结账失败");
}
return (await response.json()) as CheckoutResult;
},
);
signal 会连接到 thunk 的取消信号,fetch 能够据此中止请求。组件卸载、用户取消或新的流程取代旧流程时,可以调用 thunk 返回 promise 的 abort:
const promise = dispatch(checkout({ cartId }));
try {
const result = await promise.unwrap();
console.log("订单号", result.orderId);
} catch (error) {
// 处理拒绝、网络错误或取消
}
promise.abort();
但 createAsyncThunk 并不会自动把所有请求结果变成带缓存键的服务端缓存。若同一个商品列表被多个页面使用,还需要手写:
- 当前请求参数;
- 请求状态;
- 错误状态;
- 缓存有效期;
- 重复请求去重;
- 写入后的失效;
- 旧请求结果保护。
这正是 RTK Query 解决的问题。
四、RTK Query:Redux 生态中的服务端缓存
RTK Query 是 Redux Toolkit 提供的请求和缓存方案。它把接口定义为 endpoint,并根据参数生成缓存键。
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
type Product = {
id: string;
name: string;
price: number;
};
export const shopApi = createApi({
reducerPath: "shopApi",
baseQuery: fetchBaseQuery({
baseUrl: "/api",
}),
tagTypes: ["Product", "ProductList"],
endpoints: (build) => ({
products: build.query<Product[], { q: string }>({
query: ({ q }) => ({
url: "products",
params: { q },
}),
providesTags: ["ProductList"],
}),
updateProduct: build.mutation<
Product,
{ id: string; name: string; price: number }
>({
query: ({ id, ...body }) => ({
url: `products/${id}`,
method: "PUT",
body,
}),
invalidatesTags: (_result, _error, arg) => [
{ type: "Product", id: arg.id },
"ProductList",
],
}),
}),
});
export const {
useProductsQuery,
useUpdateProductMutation,
} = shopApi;
Store 必须挂载 API reducer 和 middleware:
import { configureStore } from "@reduxjs/toolkit";
import { shopApi } from "./shopApi";
export const store = configureStore({
reducer: {
[shopApi.reducerPath]: shopApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(shopApi.middleware),
});
组件使用:
function ProductList({ q }: { q: string }) {
const {
data = [],
isLoading,
isFetching,
isError,
error,
} = useProductsQuery({ q });
if (isLoading) return <p>首次加载中……</p>;
if (isError) return <p>加载失败:{String(error)}</p>;
return (
<section aria-busy={isFetching}>
{isFetching && <small>正在更新……</small>}
<ul>
{data.map((product) => (
<li key={product.id}>
{product.name}:{product.price}
</li>
))}
</ul>
</section>
);
}
这里要区分:
isLoading:当前没有可用数据,首次请求仍在进行;isFetching:正在请求,可能已经有旧数据可展示;isError:当前请求处于错误状态;data:缓存中当前可用的数据。
一个重要的用户体验差异是:重新搜索时,旧结果可以继续展示,同时标记 isFetching,而不是把页面强制清空。这体现了“缓存数据”和“请求状态”是两个维度。
4.1 缓存键和缓存失效
对于查询:
useProductsQuery({ q: "keyboard" });
参数会参与缓存键。概念上:
于是:
("products", { q: "keyboard" })
("products", { q: "mouse" })
是两个缓存条目。
Mutation 成功后,invalidatesTags 使相关查询重新验证。它并不是数据库事务,也不保证所有外部客户端立刻同步;它只是告诉当前客户端:“这些缓存不能继续被无条件信任”。
如果 mutation 失败,相关缓存通常不应被无条件失效;代码应根据错误语义决定是否重试、提示用户或保留旧数据。
4.2 RTK Query 的生命周期
典型路径是:
组件订阅 query
↓
根据 endpoint + 参数查找缓存
├── 有可用缓存:立即返回缓存
└── 没有缓存:发起请求
↓
成功:写入缓存
失败:记录错误
↓
组件取消订阅
↓
缓存保留一段时间,之后可被清理
具体缓存保留时间、轮询、重新聚焦时刷新等行为由配置和版本能力决定,应以当前 RTK Query 文档为准。不能把“缓存存在”理解为“永远新鲜”。
服务端渲染还需要额外考虑:
- 服务端 Store 必须按请求创建,不能让多个用户共享同一个 Store;
- 预取的数据需要正确脱水并在客户端恢复;
- 不应把包含用户权限的数据写入公共缓存;
- 请求取消和客户端接管要避免重复发起请求。
五、Zustand:轻量的客户端 Store 与选择器订阅
Zustand 的核心模型是一个外部 Store:
import { create } from "zustand";
type SidebarState = {
open: boolean;
toggle: () => void;
setOpen: (open: boolean) => void;
};
export const useSidebarStore = create<SidebarState>((set) => ({
open: false,
toggle: () => set((state) => ({ open: !state.open })),
setOpen: (open) => set({ open }),
}));
组件只订阅需要的字段:
function SidebarButton() {
const open = useSidebarStore((state) => state.open);
const toggle = useSidebarStore((state) => state.toggle);
return (
<button onClick={toggle} aria-expanded={open}>
{open ? "关闭侧边栏" : "打开侧边栏"}
</button>
);
}
useSidebarStore 同时是 React hook 和 Store 访问入口。选择器:
(state) => state.open
定义了组件的订阅边界。只要选择结果保持相等,组件就不必因为 Store 中其他字段变化而重新渲染。
5.1 Zustand 适合什么
Zustand 通常适合:
- 小型到中型的客户端共享状态;
- 交互状态和编辑器状态;
- 不希望建立 Redux Provider 层级的场景;
- 需要简单 selector 订阅的场景;
- 状态更新规则不需要完整 action 日志的场景。
例如拖拽状态:
type DragState = {
activeId: string | null;
start: (id: string) => void;
end: () => void;
};
export const useDragStore = create<DragState>((set) => ({
activeId: null,
start: (activeId) => set({ activeId }),
end: () => set({ activeId: null }),
}));
5.2 Zustand 不会自动变成服务端缓存
下面这种写法可以请求数据,但必须自行定义缓存语义:
type User = {
id: string;
name: string;
};
type UserState = {
user: User | null;
status: "idle" | "loading" | "success" | "error";
error: string | null;
load: (id: string, signal?: AbortSignal) => Promise<void>;
};
export const useUserStore = create<UserState>((set) => ({
user: null,
status: "idle",
error: null,
async load(id, externalSignal) {
set({ status: "loading", error: null });
try {
const response = await fetch(`/api/users/${id}`, {
signal: externalSignal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const user = (await response.json()) as User;
set({ user, status: "success" });
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
set({
status: "error",
error: error instanceof Error ? error.message : "未知错误",
});
}
},
}));
此 Store 仍然缺少:
- 相同
id的请求去重; - 多个
id的独立缓存; - 缓存有效期;
- 后台重新验证;
- mutation 对相关数据的精确失效;
- 并发请求顺序保护。
如果这些需求出现,使用专门的服务端缓存库通常比继续扩展这个 Store 更清晰。Zustand 可以和 RTK Query、React Router loader 或其他请求缓存方案共存,但不应让两个系统同时拥有同一份远端资源。
5.3 SSR 与模块级 Store 的风险
在纯客户端应用中,模块级 Zustand Store 很方便。在服务端渲染环境中,必须注意请求隔离:
请求 A 的用户状态 ─┐
├── 不能写入同一个全局服务端 Store
请求 B 的用户状态 ─┘
如果服务端进程中的模块级 Store 被多个请求共享,用户 A 的数据可能被用户 B 读到。这不是 React 重渲染问题,而是服务端并发请求之间的内存隔离问题。
需要用户相关、请求相关或租户相关的 Store 时,应按请求创建 Store,并通过 props、Context 或框架提供的请求上下文传递。浏览器端 hydration 时还要保证服务端初始快照与客户端首次快照一致,否则可能出现 hydration mismatch。
六、服务端缓存:缓存的是资源,不是任意状态
服务端缓存的基本对象可以表示为:
type QueryEntry<T> = {
key: string;
data?: T;
status: "idle" | "loading" | "success" | "error";
error?: unknown;
fetchedAt?: number;
};
定义:
key:请求资源和参数的规范化标识;data:最近一次成功结果;fetchedAt:成功获取时间;status:本次请求状态;error:当前请求错误。
若当前时间为 ,缓存年龄为:
设置新鲜期 后:
但“新鲜”只是客户端策略,不是服务端事实。服务端数据可能在 时已经发生变化,也可能在很久未刷新时仍然没有变化。
常见策略有:
6.1 Cache-first
有缓存 → 先展示缓存
无缓存 → 请求服务器
适合读多写少、旧数据可接受的页面。
6.2 Stale-while-revalidate
有旧缓存 → 立即展示
同时请求服务器 → 成功后替换缓存
适合列表、仪表盘和用户希望快速看到内容的场景。
6.3 Network-first
优先请求服务器
请求失败 → 如果有缓存则回退到缓存
适合准确性比响应速度更重要的数据,例如权限或余额展示。即使如此,最终写操作仍必须由服务端校验,不能信任缓存中的权限。
6.4 写入后的协调
写操作成功后有三种常见办法:
- 失效并重新获取:最简单、最可靠,但会增加请求。
- 直接更新缓存:减少请求,但必须正确处理服务端规范化字段、排序、权限和并发。
- 乐观更新:先修改 UI,失败后回滚;体验好,但回滚和并发冲突复杂。
例如,用户修改名称后,服务端可能自动去除空格、生成审计字段或触发权限变化。客户端直接把提交参数写回缓存,未必等于服务端真实结果。优先使用服务端返回的规范化对象,或者成功后重新获取。
七、请求取消与并发:旧结果不能覆盖新意图
考虑搜索框:
t0: 输入 "r" → 请求 A
t1: 输入 "re" → 请求 B
t2: 输入 "react" → 请求 C
t3: C 返回
t4: A 返回
如果每个请求返回后都直接设置结果,最终页面可能显示 "r" 的结果,因为 A 最后返回。请求完成顺序不等于用户意图顺序。
可用 AbortController 取消旧请求:
import { useEffect, useState } from "react";
type SearchResult = {
id: string;
title: string;
};
export function SearchResults({ query }: { query: string }) {
const [data, setData] = useState<SearchResult[]>([]);
const [status, setStatus] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
useEffect(() => {
if (!query.trim()) {
setData([]);
setStatus("idle");
return;
}
const controller = new AbortController();
setStatus("loading");
fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return (await response.json()) as SearchResult[];
})
.then((result) => {
setData(result);
setStatus("success");
})
.catch((error: unknown) => {
if (
error instanceof DOMException &&
error.name === "AbortError"
) {
return;
}
setStatus("error");
});
return () => controller.abort();
}, [query]);
if (status === "error") return <p>搜索失败</p>;
return (
<ul aria-busy={status === "loading"}>
{data.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
);
}
这个实现的因果链是:
query 变化
↓
清理上一次 effect
↓
abort 旧请求
↓
发起新请求
↓
旧请求若拒绝为 AbortError,则不更新错误 UI
但取消不是绝对的并发一致性保证。请求可能已经到达服务器,服务器端写操作也可能已经执行。取消通常只影响客户端等待和处理响应的过程,不能撤销已经完成的服务器副作用。
对于不可取消或需要强一致的请求,还应使用请求序列号、版本号或服务端幂等键。例如:
let latestRequest = 0;
async function loadLatest(query: string) {
const requestId = ++latestRequest;
const result = await fetchResult(query);
if (requestId !== latestRequest) {
return; // 丢弃旧结果
}
renderResult(result);
}
在全局缓存工具中,这类顺序保护通常由查询键和请求生命周期共同处理,但写 mutation 的冲突仍需要业务层设计。
八、React Router:URL、loader 和服务端缓存的交界
React Router 的数据路由可以把“进入路由所需的数据”放到 loader 中,而不是让页面挂载后再由 useEffect 发起请求。
一个简化示例:
import {
createBrowserRouter,
RouterProvider,
useLoaderData,
type LoaderFunctionArgs,
} from "react-router-dom";
type Product = {
id: string;
name: string;
};
async function productsLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const q = url.searchParams.get("q") ?? "";
const response = await fetch(
`/api/products?q=${encodeURIComponent(q)}`,
{ signal: request.signal },
);
if (!response.ok) {
throw new Response("加载商品失败", {
status: response.status,
});
}
return (await response.json()) as Product[];
}
function ProductsPage() {
const products = useLoaderData() as Product[];
return (
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
const router = createBrowserRouter([
{
path: "/products",
loader: productsLoader,
element: <ProductsPage />,
errorElement: <p>商品加载失败</p>,
},
]);
export function App() {
return <RouterProvider router={router} />;
}
这里的 request.signal 很重要。当用户快速切换路由时,路由系统可以取消不再需要的 loader 请求。errorElement 处理的是路由数据加载错误,不应与组件内部表单校验错误混为一谈。
React Router loader 主要解决:
- 路由进入前需要什么数据;
- URL 参数如何驱动数据加载;
- 导航取消;
- 路由级错误边界;
- 数据加载与导航生命周期的关联。
它不自动等于通用客户端缓存。对于跨多个路由共享、需要失效和后台重新验证的资源,可以使用 RTK Query 等缓存方案;对于只属于某个路由进入过程的数据,loader 可能已经足够。
应避免以下重复结构:
loader 请求 /products
页面 useEffect 再请求 /products
RTK Query 又请求 /products
这会造成重复请求和三个独立的数据生命周期。应明确其中一个为主要读取路径,其他层只负责必要的适配。
九、Redux Toolkit、Zustand、服务端缓存和 Context 的选择边界
可以用“状态拥有者 + 复杂度”而不是库的流行度来判断。
| 场景 | 首选方向 | 原因 |
|---|---|---|
| 单个输入框、展开状态 | useState |
生命周期最短,边界最清晰 |
| 跨层主题、编辑器上下文 | Context,必要时配 Reducer | React 原生依赖注入和状态转移 |
| 简单全局客户端状态 | Zustand | Store 小、selector 直接、样板少 |
| 复杂客户端领域状态 | Redux Toolkit | action、Reducer、中间件、DevTools、严格结构 |
| Redux 项目的远端资源 | RTK Query | 查询键、缓存、标签失效、请求生命周期 |
| 路由进入所需数据 | React Router loader | 与导航、URL、取消和错误边界集成 |
| 搜索分页筛选 | URL 参数 + 数据缓存 | 可分享、可回退、可恢复 |
| 需要跨刷新保存的偏好 | 持久化 Store 或服务端用户设置 | 明确数据敏感性和版本迁移 |
一个具体决策过程如下。
情况一:购物车
购物车可能包含服务端和客户端两部分:
- 商品价格、库存、促销规则由服务端拥有;
- 当前选择的商品数量由客户端暂存;
- 提交结账时服务端必须重新验证价格和库存。
如果只是一个页面中的临时购物车,组件状态或 Context + Reducer 足够。如果购物车跨多个页面、需要审计 action、复杂优惠规则和持久化迁移,RTK 的 slice 更适合。不要把客户端缓存中的商品价格当作结账依据。
情况二:商品查询
商品列表是典型服务端状态。RTK Query、React Router loader 或专门的数据缓存库都比单纯 Zustand 更自然,因为它们可以表达:
查询参数 → 缓存键 → 加载状态 → 错误 → 重新验证 → mutation 失效
如果页面只在一个路由中使用,loader 可能足够;如果多个页面需要同一资源并共享缓存,RTK Query 更适合。
情况三:复杂编辑器
编辑器草稿不是服务端缓存,至少在未保存前不是。它通常具有:
- 多个区域共享;
- 多种 action;
- 撤销/重做;
- 脏状态;
- 校验;
- 自动保存;
- 冲突处理。
Reducer 可以表达纯状态转移;Redux Toolkit 可以提供 action 日志和中间件;Zustand 可以减少样板。若团队需要严格审计和可重放,Redux Toolkit 更有优势;若状态结构中等且主要关注开发效率,Zustand 可能更轻。
十、持久化、权限和安全边界
localStorage 只能保存字符串,常见持久化方式是序列化:
localStorage.setItem(
"preferences",
JSON.stringify({ theme: "dark" }),
);
读取时必须考虑损坏数据和版本迁移:
type Preferences = {
version: 1;
theme: "light" | "dark";
};
function readPreferences(): Preferences {
try {
const raw = localStorage.getItem("preferences");
if (!raw) {
return { version: 1, theme: "light" };
}
const value = JSON.parse(raw) as Partial<Preferences>;
if (
value.version !== 1 ||
(value.theme !== "light" && value.theme !== "dark")
) {
return { version: 1, theme: "light" };
}
return value as Preferences;
} catch {
return { version: 1, theme: "light" };
}
}
不要把以下内容默认放入 localStorage:
- 长期有效的访问令牌;
- 密码;
- 服务端权限判断依据;
- 其他脚本不应读取的敏感数据。
客户端 Store 和缓存都属于用户可控制的运行环境。它们可以改善界面体验,但不能替代服务端鉴权。服务端必须根据自己的会话、权限和资源归属重新判断请求是否允许。
十一、常见失败表现与诊断路径
11.1 页面显示旧数据
可能原因:
- 查询参数没有进入缓存键;
- mutation 成功后没有失效相关缓存;
- 多个 Store 各自保存了一份资源;
- 旧请求晚返回并覆盖新结果;
- 服务端本身存在最终一致性延迟。
诊断顺序应是:
确认请求 URL 和参数
↓
确认缓存键是否变化
↓
确认写入响应是否成功
↓
确认失效或缓存更新是否发生
↓
确认是否存在旧请求覆盖
↓
最后检查服务端数据和缓存层
不要先通过“强制刷新所有数据”掩盖缓存键错误。这样可能暂时显示正确,却保留了根因。
11.2 组件渲染次数过多
应先区分:
- 状态确实改变了;
- selector 返回了新引用;
- Context value 引用每次都变化;
- Provider 本身因为父组件渲染而重渲染;
- React 开发模式下的额外检查行为。
对于 Redux 和 Zustand,应检查 selector 返回值;对于 Context,应拆分 Provider 或稳定 value;对于局部状态,应确认是否错误提升到了根组件。
“渲染次数多”不等于性能问题。真正需要测量的是渲染耗时、提交耗时和用户可感知延迟。
11.3 刷新后状态丢失
先判断该状态是否本来就应该丢失:
- 弹窗打开状态通常不必持久化;
- 搜索词可能应进入 URL;
- 用户主题偏好可以持久化;
- 订单数据应重新从服务端获取;
- 未保存草稿可能需要本地恢复,但必须处理版本和过期。
把所有状态持久化只会把临时状态变成迁移和失效问题。
11.4 SSR 后出现用户数据串线
重点检查:
- 服务端是否使用了模块级单例 Store;
- 请求之间是否共享了缓存对象;
- hydration 的初始状态是否来自同一个请求;
- 是否把用户专属数据放进了公共 HTTP 缓存;
- 客户端恢复时是否混用了旧用户会话。
这是边界隔离失败,不是简单的组件状态 bug。
十二、一个可执行的组合结构
一个典型应用可以按以下方式组织:
app/
store/
index.ts # Redux Store
cartSlice.ts # 客户端购物车
api.ts # RTK Query 服务端缓存
routes/
productsLoader.ts # 路由级加载
components/
SearchBox.tsx # URL 参数更新
ProductList.tsx # 读取查询缓存
CartButton.tsx # 读取客户端 Store
组件之间的数据流可以是:
SearchBox
└── 修改 URL ?q=...
↓
路由或查询 hook
└── 以 q 生成缓存键
↓
服务端商品列表缓存
↓
ProductList 展示 data
CartButton
└── dispatch(itemAdded(...))
↓
Redux Toolkit cart slice
↓
购物车 UI 更新
提交结账
└── 读取 cart slice
↓
POST /api/checkout
↓
服务端重新校验商品、价格、库存和权限
其中商品列表和购物车故意属于两个不同的拥有者:
- 商品列表由服务端拥有,客户端缓存它;
- 购物车选择由客户端暂存,但结账时服务端重新验证;
- 结账成功后,可以清空客户端购物车,并让相关订单查询失效或重新获取。
十三、最终的边界判断
选择 Redux Toolkit、Zustand 或服务端缓存,不应从“哪个库更简单”开始,而应从状态转移和数据所有权开始:
可以把这条规则落实为:
- 先尝试局部状态,不要为了共享而过早全局化。
- 跨层但属于客户端的状态,使用 Context + Reducer、Zustand 或 Redux Toolkit。
- 需要 action 追踪、复杂 reducer、中间件和团队约束,优先 Redux Toolkit。
- 需要轻量共享状态和精确选择器订阅,优先 Zustand。
- 由服务端拥有的数据,优先使用 RTK Query、React Router loader 或其他服务端缓存方案。
- 搜索、分页、筛选和排序,优先考虑 URL。
- 请求取消只能减少客户端无效工作,不能撤销已执行的服务端副作用。
- 缓存失效解决的是客户端新鲜度,不等于分布式系统中的强一致事务。
- 任何客户端状态都不能成为权限和结账正确性的最终依据。
- SSR 环境必须按请求隔离状态,尤其是用户相关 Store 和缓存。
当一份数据的拥有者、生命周期和更新路径能够被明确描述时,库的选择通常会变得直接;当这些问题无法回答时,继续增加 Store、Provider 或缓存配置只会把边界问题隐藏得更深。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 异步数据:请求取消、缓存、并发、Suspense 和错误恢复
- 下一篇:React 与 TypeScript:Props、事件、泛型组件、Ref 和联合类型
- 延伸:React Context 与 Reducer:跨层状态、更新边界和可测试设计
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论