React 基础体系 · 第 12/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 状态管理选型:Redux Toolkit、Zustand、服务端缓存和边界

状态管理不是“选择一个全局变量库”,而是回答四个问题:

  1. 这份数据由谁拥有?
  2. 哪些组件需要读取或修改它?
  3. 它的生命周期是什么?
  4. 当网络请求、并发更新、刷新页面或服务端渲染发生时,谁负责保证一致性?

如果把所有数据都放进 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 保存的是客户端负责解释和修改的状态。两者可以协作,但不应默认合并。


一、先定义“状态”:拥有者决定工具

设一个状态值为 xx,判断它应放在哪里,可以先问三个问题:

  • O(x)O(x):谁是它的权威拥有者?
  • L(x)L(x):它需要存活多久?
  • S(x)S(x):哪些组件需要订阅它?

如果后端数据库是权威来源,那么通常:

O(x)=ServerO(x) = \text{Server}

客户端保存的只是缓存:

C(x)=Server 在某个时间点的客户端副本C(x) = \text{Server 在某个时间点的客户端副本}

而不是新的权威数据。缓存可能过期、被重新验证,甚至因为权限变化而失效。

例如,当前用户的订单列表通常由服务端拥有:

服务端数据库中的订单
        ↓
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 的状态转移可以形式化为:

sn+1=R(sn,an)s_{n+1} = R(s_n, a_n)

其中:

  • sns_n 是第 nn 次更新前的状态;
  • ana_n 是 action;
  • RR 是纯函数;
  • sn+1s_{n+1} 是新状态。

例如:

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 的核心约束是:

  1. Store 中有一棵状态树;
  2. 组件通过 dispatch(action) 描述事件;
  3. Reducer 根据旧状态和 action 计算新状态;
  4. Reducer 必须是纯函数;
  5. 状态更新通过不可变语义完成。

Redux Toolkit(RTK)是官方推荐的 Redux 使用方式。它通过 configureStorecreateSlicecreateAsyncThunk、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" });

参数会参与缓存键。概念上:

K=(endpointName,serializedArgs)K = (\text{endpointName}, \text{serializedArgs})

于是:

("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:当前请求错误。

若当前时间为 tt,缓存年龄为:

A=tfetchedAtA = t - fetchedAt

设置新鲜期 FF 后:

fresh    A<F\text{fresh} \iff A < F

但“新鲜”只是客户端策略,不是服务端事实。服务端数据可能在 A<FA < F 时已经发生变化,也可能在很久未刷新时仍然没有变化。

常见策略有:

6.1 Cache-first

有缓存 → 先展示缓存
无缓存 → 请求服务器

适合读多写少、旧数据可接受的页面。

6.2 Stale-while-revalidate

有旧缓存 → 立即展示
同时请求服务器 → 成功后替换缓存

适合列表、仪表盘和用户希望快速看到内容的场景。

6.3 Network-first

优先请求服务器
请求失败 → 如果有缓存则回退到缓存

适合准确性比响应速度更重要的数据,例如权限或余额展示。即使如此,最终写操作仍必须由服务端校验,不能信任缓存中的权限。

6.4 写入后的协调

写操作成功后有三种常见办法:

  1. 失效并重新获取:最简单、最可靠,但会增加请求。
  2. 直接更新缓存:减少请求,但必须正确处理服务端规范化字段、排序、权限和并发。
  3. 乐观更新:先修改 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 页面显示旧数据

可能原因:

  1. 查询参数没有进入缓存键;
  2. mutation 成功后没有失效相关缓存;
  3. 多个 Store 各自保存了一份资源;
  4. 旧请求晚返回并覆盖新结果;
  5. 服务端本身存在最终一致性延迟。

诊断顺序应是:

确认请求 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 或服务端缓存,不应从“哪个库更简单”开始,而应从状态转移和数据所有权开始:

工具选择=f(拥有者,生命周期,订阅范围,更新复杂度,一致性要求)\text{工具选择} = f(\text{拥有者}, \text{生命周期}, \text{订阅范围}, \text{更新复杂度}, \text{一致性要求})

可以把这条规则落实为:

  1. 先尝试局部状态,不要为了共享而过早全局化。
  2. 跨层但属于客户端的状态,使用 Context + Reducer、Zustand 或 Redux Toolkit。
  3. 需要 action 追踪、复杂 reducer、中间件和团队约束,优先 Redux Toolkit。
  4. 需要轻量共享状态和精确选择器订阅,优先 Zustand。
  5. 由服务端拥有的数据,优先使用 RTK Query、React Router loader 或其他服务端缓存方案。
  6. 搜索、分页、筛选和排序,优先考虑 URL。
  7. 请求取消只能减少客户端无效工作,不能撤销已执行的服务端副作用
  8. 缓存失效解决的是客户端新鲜度,不等于分布式系统中的强一致事务
  9. 任何客户端状态都不能成为权限和结账正确性的最终依据
  10. SSR 环境必须按请求隔离状态,尤其是用户相关 Store 和缓存

当一份数据的拥有者、生命周期和更新路径能够被明确描述时,库的选择通常会变得直接;当这些问题无法回答时,继续增加 Store、Provider 或缓存配置只会把边界问题隐藏得更深。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。