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

React 状态建模:最小状态、派生值、归一化和所有权

React 中的“状态问题”通常不是 useState API 不会用,而是没有先回答四个问题:

  1. 哪些值必须跨渲染保存?
  2. 哪些值可以由已有数据计算出来?
  3. 多个对象之间如何避免重复保存和不一致?
  4. 哪个组件或系统边界拥有修改某个值的权力?

这四个问题分别对应最小状态派生值归一化所有权。它们共同决定组件的数据模型、更新方式以及并发渲染下是否可靠。


一、先区分 React 中的几类数据

“数据”不等于“状态”。在建模之前,至少要区分以下几类来源。

1. Props:父组件传入的当前输入

Props 是组件从外部接收的数据:

type UserCardProps = {
  name: string;
  avatarUrl: string;
};

function UserCard({ name, avatarUrl }: UserCardProps) {
  return (
    <article>
      <img src={avatarUrl} alt="" />
      <span>{name}</span>
    </article>
  );
}

UserCard 不拥有 nameavatarUrl 的修改权。它只能根据当前 props 渲染结果。

2. State:组件需要在渲染之间保留的可变事实

State 是 React 为组件保存的输入。调用状态更新函数后,React 会安排新的渲染:

const [isOpen, setIsOpen] = useState(false);

isOpen 是一个事实:菜单当前是否打开。它不能只依靠当前 props 计算出来,并且用户操作会改变它。

React 的状态值应当被看作某次渲染的快照。在一个事件处理函数中,isOpen 不会因为调用 setIsOpen 就立即改变:

function Toggle() {
  const [isOpen, setIsOpen] = useState(false);

  function handleClick() {
    console.log(isOpen); // 当前这次渲染中的值
    setIsOpen(true);
    console.log(isOpen); // 仍然是当前快照中的值
  }

  return <button onClick={handleClick}>打开</button>;
}

React 可能批量处理多个更新,因此更新函数不能依赖“调用后变量立刻变化”。

3. 派生值:由当前输入计算出来的结果

派生值不是独立事实,而是函数的结果:

const visibleItems = items.filter((item) => item.category === category);

只要 itemscategory 相同,visibleItems 就应当相同。通常不需要单独存储。

4. 外部系统数据:缓存、URL、浏览器 API、服务端数据

例如:

  • URL 中的 ?page=2
  • 浏览器的 localStorage
  • WebSocket 连接状态
  • 服务端数据库中的订单
  • 查询缓存中的请求结果

这些数据可能需要通过专门的边界或库接入,而不是全部复制进组件 state。React 官方提供的 useSyncExternalStore 用于订阅外部 store;服务端数据也常由框架的数据获取机制或查询库管理。

关键问题不是“能不能放进 state”,而是“哪个系统才是这个值的权威来源”。


二、最小状态:只保存不能由其他输入推出的事实

2.1 形式化定义

设组件在一次渲染中的外部输入为 PP,本地状态为 SS,渲染结果为:

V=R(P,S)V = R(P, S)

如果某个值 DD 可以由已有输入唯一计算:

D=f(P,S)D = f(P, S)

那么 DD 通常不应再作为独立 state 保存。组件只需要保存能够决定它的最小事实集合。

例如:

const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");

全名满足:

fullName=firstName+""+lastNamefullName = firstName + " " + lastName

因此:

const fullName = `${firstName} ${lastName}`.trim();

比下面这种模型更可靠:

const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [fullName, setFullName] = useState("");

后者引入了一个必须始终满足的约束:

fullName=g(firstName,lastName)fullName = g(firstName, lastName)

每个输入更新都必须同步更新 fullName。一旦某条路径忘记同步,模型就不一致。

2.2 “最小”不是“状态变量越少越好”

最小状态指的是没有保存可以由其他事实确定的重复信息,而不是把所有东西强行压成一个对象。

下面的两个 state 都可能是独立事实:

const [isDialogOpen, setDialogOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);

虽然弹窗显示时通常需要 selectedId,但“当前选中哪个项目”和“弹窗是否打开”可能有不同的生命周期:

  • 用户可以先选中项目但不打开弹窗;
  • 弹窗关闭后仍保留选中项;
  • 删除项目后需要清理选中项。

是否合并,取决于领域约束,而不是变量数量。

相反,下面的 hasSelection 是冗余的:

const [selectedId, setSelectedId] = useState<string | null>(null);
const [hasSelection, setHasSelection] = useState(false);

它应当写成:

const hasSelection = selectedId !== null;

2.3 判定一个值是否应该进入 state

可以按以下顺序推导:

第一步:它是否会跨渲染保留?

如果只是函数内部的临时变量,不需要 state:

function Price({ cents }: { cents: number }) {
  const formatted = `$${(cents / 100).toFixed(2)}`;
  return <span>{formatted}</span>;
}

第二步:它是否由 props、state 或常量唯一决定?

如果是,则优先计算:

const selectedProduct = products.find((p) => p.id === selectedId);

第三步:它是否需要由用户操作或外部事件独立改变?

如果用户可以直接改变它,或者它代表一个不能从其他当前输入推出的事实,才考虑 state:

const [query, setQuery] = useState("");

第四步:它是否实际属于外部系统?

如果 URL、服务端、浏览器存储或 WebSocket 才是权威来源,就不应把 React state 当作唯一真相。React state 可以作为局部草稿或缓存,但必须定义同步方向和冲突策略。


三、派生值:在渲染中计算,而不是用 Effect 复制

3.1 派生值的定义

派生值是由已有数据通过确定性函数得到的值:

const completedCount = todos.filter((todo) => todo.done).length;
const remainingCount = todos.length - completedCount;

这里真正的事实是 todoscompletedCountremainingCount 都是投影:

completedCount={ttodost.done=true}completedCount = \left|\{t \in todos \mid t.done = true\}\right|

3.2 常见错误:用 Effect 同步派生 state

不推荐:

const [completedCount, setCompletedCount] = useState(0);

useEffect(() => {
  setCompletedCount(todos.filter((todo) => todo.done).length);
}, [todos]);

这里会产生这样的过程:

  1. todos 变化;
  2. React 先使用旧的 completedCount 完成一次渲染;
  3. Effect 在提交后运行;
  4. Effect 更新 completedCount
  5. React 再次渲染。

这会带来额外渲染,并且在步骤 2 中短暂得到不一致的 UI。更直接的写法是:

const completedCount = todos.filter((todo) => todo.done).length;

useEffect 的主要职责是与渲染之外的系统同步,例如设置文档标题、订阅事件或发起具有副作用的请求,而不是把一个纯计算结果复制到另一个 state。

3.3 复杂计算与 useMemo

如果派生计算确实昂贵,可以使用:

const visibleProducts = useMemo(() => {
  return products
    .filter((product) => product.name.includes(query))
    .sort((a, b) => a.price - b.price);
}, [products, query]);

但要区分两种含义:

  • visibleProducts 在语义上仍然是派生值;
  • useMemo 只是性能优化,不是数据一致性的机制。

useMemo 不应被当作业务状态。代码必须在不依赖缓存永久存在的前提下仍然正确。React 可以在某些情况下丢弃 memoized value,重新计算并不改变语义。

如果计算很便宜,直接计算通常更清晰:

const visibleProducts = products
  .filter((product) => product.name.includes(query))
  .sort((a, b) => a.price - b.price);

3.4 派生值的边界:用户编辑中的草稿

“服务端用户名”和“用户正在编辑的输入框内容”不是同一个值。

const [draftName, setDraftName] = useState(user.name);

draftName 初始来自 user.name,但之后用户可以独立修改它,因此它是一个编辑草稿,不是简单派生值。

问题在于:当 user 换成另一个用户时,是否重置草稿?这不是计算规则,而是明确的生命周期决策。可以使用 key 让整个编辑器按用户身份重建:

<UserEditor key={user.id} user={user} />

这样,当 user.id 改变时,UserEditor 的 state 会被重置。若产品要求保留未提交草稿,则需要更复杂的草稿所有权和恢复策略,不能仅靠“同步 Effect”自动猜测。


四、完整算例:购物车中的最小状态和派生值

假设商品目录来自服务端,购物车由当前页面负责编辑。合理的模型是:

  • products:外部输入或服务端数据;
  • cart:本地事实,只保存商品 ID 到数量的映射;
  • 商品名称和价格:从 products 派生;
  • 小计和总价:从购物车数量与商品价格派生;
  • 不保存 cartItemssubtotaltotal 这些重复结果。
import { useMemo, useReducer } from "react";

type Product = {
  id: string;
  name: string;
  priceCents: number;
};

type CartState = Record<string, number>;

type CartAction =
  | { type: "add"; productId: string }
  | { type: "remove"; productId: string }
  | { type: "clear" };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "add": {
      const current = state[action.productId] ?? 0;

      return {
        ...state,
        [action.productId]: current + 1,
      };
    }

    case "remove": {
      const current = state[action.productId] ?? 0;

      if (current <= 1) {
        const next = { ...state };
        delete next[action.productId];
        return next;
      }

      return {
        ...state,
        [action.productId]: current - 1,
      };
    }

    case "clear":
      return {};

    default:
      return state;
  }
}

type CartProps = {
  products: Product[];
};

export function Cart({ products }: CartProps) {
  const [cart, dispatch] = useReducer(cartReducer, {});

  const productById = useMemo(() => {
    return new Map(products.map((product) => [product.id, product]));
  }, [products]);

  const lines = Object.entries(cart)
    .map(([productId, quantity]) => {
      const product = productById.get(productId);

      // 商品已从目录中移除时,不渲染无效行。
      if (!product) {
        return null;
      }

      return {
        product,
        quantity,
        subtotalCents: product.priceCents * quantity,
      };
    })
    .filter(
      (
        line,
      ): line is {
        product: Product;
        quantity: number;
        subtotalCents: number;
      } => line !== null,
    );

  const totalCents = lines.reduce(
    (sum, line) => sum + line.subtotalCents,
    0,
  );

  return (
    <section>
      <h2>购物车</h2>

      {products.map((product) => (
        <div key={product.id}>
          <span>
            {product.name}:¥{(product.priceCents / 100).toFixed(2)}
          </span>

          <button
            type="button"
            onClick={() =>
              dispatch({ type: "remove", productId: product.id })
            }
          >
            -
          </button>

          <span>{cart[product.id] ?? 0}</span>

          <button
            type="button"
            onClick={() => dispatch({ type: "add", productId: product.id })}
          >
            +
          </button>
        </div>
      ))}

      <p>商品种类:{lines.length}</p>
      <p>总价:¥{(totalCents / 100).toFixed(2)}</p>

      <button type="button" onClick={() => dispatch({ type: "clear" })}>
        清空
      </button>
    </section>
  );
}

这个模型中每一步为何成立

初始状态:

{}

表示没有任何商品。用户增加 p1

{ type: "add", productId: "p1" }

状态变为:

{ p1: 1 }

再次增加后:

{ p1: 2 }

如果 p1 的单价是 1999 分,则:

subtotalp1=1999×2=3998subtotal_{p1} = 1999 \times 2 = 3998

当目录中有 p2,购物车变为:

{
  p1: 2,
  p2: 1
}

lines 根据 ID 查找商品,再计算每行小计;totalCents 只是所有行小计之和。它们都不需要进入 reducer state。

这里使用整数分而不是浮点数金额,避免:

0.1 + 0.2 // 可能得到 0.30000000000000004

金额精度是领域建模问题,不是 React 特性。若涉及税率、折扣和货币换算,还需要进一步定义舍入规则。

反例:同时保存多个可互相推出的集合

不推荐:

type BadState = {
  cartItems: Product[];
  quantities: Record<string, number>;
  totalCents: number;
};

这个模型至少有三个一致性约束:

cartItems=lookup(quantities,products)cartItems = lookup(quantities, products)

totalCents=price(product)×quantity(product)totalCents = \sum price(product) \times quantity(product)

任何“删除商品”“价格更新”“恢复购物车”的路径都可能只修改其中一部分。归一化和派生值的作用,就是减少这些必须手动维护的约束。


五、归一化:把重复对象转换为实体和引用

5.1 归一化的定义

归一化是把嵌套、重复的数据结构转换为:

  1. 以 ID 索引的实体表;
  2. 表示关系的 ID 引用;
  3. 必要的排序或分组数组。

例如,一个评论列表可能重复包含作者信息:

const posts = [
  {
    id: "post-1",
    author: { id: "u-1", name: "A" },
    comments: [
      { id: "c-1", author: { id: "u-1", name: "A" }, text: "..." },
    ],
  },
];

归一化后:

type NormalizedState = {
  users: Record<string, User>;
  posts: Record<string, Post>;
  comments: Record<string, Comment>;
  postIds: string[];
  commentIdsByPostId: Record<string, string[]>;
};

type User = {
  id: string;
  name: string;
};

type Post = {
  id: string;
  authorId: string;
  title: string;
};

type Comment = {
  id: string;
  postId: string;
  authorId: string;
  text: string;
};

同一个用户只保存一次:

users: {
  "u-1": { id: "u-1", name: "A" }
}

帖子和评论通过 authorId 引用它。

5.2 为什么重复嵌套会造成问题

假设用户改名。重复结构需要更新:

  • 帖子中的作者;
  • 每条评论中的作者;
  • 其他列表和缓存中的作者。

如果有一处没有更新,界面会出现同一用户多个名字。归一化后只需更新:

users["u-1"].name = "新名字";

在 React 中不能直接修改原对象,而要创建新的引用:

setState((previous) => ({
  ...previous,
  users: {
    ...previous.users,
    "u-1": {
      ...previous.users["u-1"],
      name: "新名字",
    },
  },
}));

归一化降低的不是所有操作的代码行数,而是重复数据导致的不一致风险

5.3 归一化与派生值必须配合

归一化状态不应直接作为最终 UI 数据。组件通过 selector 从实体表和引用表派生视图:

function selectCommentsForPost(
  state: NormalizedState,
  postId: string,
): Comment[] {
  const ids = state.commentIdsByPostId[postId] ?? [];

  return ids
    .map((id) => state.comments[id])
    .filter((comment): comment is Comment => comment !== undefined);
}

function selectCommentViewModels(
  state: NormalizedState,
  postId: string,
) {
  return selectCommentsForPost(state, postId).map((comment) => ({
    id: comment.id,
    text: comment.text,
    authorName: state.users[comment.authorId]?.name ?? "未知用户",
  }));
}

这里:

  • commentsusers 和 ID 关系是事实;
  • authorName 是跨实体 join 后的派生值;
  • 缺失用户时的 "未知用户" 是故障降级策略。

归一化不是把所有数据都塞进一个大对象。它需要保留关系和顺序,否则无法知道评论属于哪个帖子,也无法恢复展示顺序。

5.4 何时不必归一化

小型、只读、生命周期短的嵌套数据不一定需要归一化:

type Address = {
  city: string;
  street: string;
};

type Profile = {
  id: string;
  address: Address;
};

如果地址只属于一个 profile,且不会在多个列表中重复引用,直接嵌套更容易理解。

归一化适合这些情况:

  • 同一实体被多个视图引用;
  • 实体可以独立更新;
  • 数据关系较多;
  • 局部更新和缓存命中很重要;
  • 重复嵌套已经产生同步错误。

它也会增加 selector、实体缺失和更新路径的复杂度。为了一个只有两层、只展示一次的响应对象强行归一化,可能降低可读性。


六、状态所有权:谁是唯一权威来源

6.1 所有权不是“谁能读”

状态所有权指的是:哪个组件或系统负责保存某个事实,并决定如何更新它

子组件可以通过 props 读取数据,但不代表它拥有数据:

function Parent() {
  const [value, setValue] = useState("");

  return <Input value={value} onChange={setValue} />;
}

Input 能读 value,也能发出修改请求,但真正的所有者是 Parent

可以把所有权表示成一条数据流:

flowchart TD
    A[用户事件] --> B[拥有状态的组件]
    B --> C[状态更新]
    C --> D[重新渲染]
    D --> E[Props 下传]
    E --> F[展示组件]
    F --> A

关键路径是:

  1. 用户在展示组件中产生事件;
  2. 展示组件调用父组件传入的回调;
  3. 所有者更新 state;
  4. React 重新渲染;
  5. 新值通过 props 下传。

这种单向数据流避免了子组件和父组件分别保存同一个事实。

6.2 状态提升:多个组件需要同一事实时上移

假设两个兄弟组件都需要知道当前选中的 tab:

function Page() {
  const [activeTab, setActiveTab] = useState<"info" | "comments">("info");

  return (
    <>
      <TabBar activeTab={activeTab} onChange={setActiveTab} />
      <TabPanel activeTab={activeTab} />
    </>
  );
}

如果 TabBarTabPanel 各自保存 activeTab

// 错误方向:兄弟组件各有一份 activeTab

它们就需要额外同步,且可能在事件顺序或异步回调下短暂不一致。

状态应当提升到它们最近的共同祖先。提升并不是越高越好;如果只有一个输入框使用 query,把它提升到应用根部会扩大更新范围和认知范围。

6.3 受控与非受控

受控组件的当前值由父组件提供:

type SearchInputProps = {
  value: string;
  onChange: (value: string) => void;
};

function SearchInput({ value, onChange }: SearchInputProps) {
  return (
    <input
      value={value}
      onChange={(event) => onChange(event.target.value)}
    />
  );
}

父组件是 value 的所有者。

非受控组件则让 DOM 保存当前值,React 只在需要时读取:

function NativeForm() {
  const formRef = useRef<HTMLFormElement>(null);

  function handleSubmit(event: React.FormEvent) {
    event.preventDefault();

    const formData = new FormData(formRef.current!);
    console.log(formData.get("email"));
  }

  return (
    <form ref={formRef} onSubmit={handleSubmit}>
      <input name="email" type="email" />
      <button type="submit">提交</button>
    </form>
  );
}

这不是“受控一定好、非受控一定坏”。如果每次按键都需要驱动 React UI,受控更合适;如果只是提交表单,交给浏览器管理输入值可能更简单。

不要在同一个输入生命周期中随意从非受控切换到受控,例如初始 valueundefined,之后变成字符串,通常会产生 React 警告并暴露不明确的所有权。


七、Context、Reducer 和外部 Store 的关系

7.1 Context 解决传递,不自动解决所有权

Context 让深层组件读取共同数据:

const ThemeContext = createContext<"light" | "dark">("light");

但 Context 本身不决定谁修改数据。通常是:

type ThemeContextValue = {
  theme: "light" | "dark";
  setTheme: (theme: "light" | "dark") => void;
};

const ThemeContext = createContext<ThemeContextValue | null>(null);

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<"light" | "dark">("light");

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

这里 ThemeProvider 才是所有者,Context 只是访问通道。

Context value 如果每次渲染都创建新的大对象,所有消费者可能跟着重新渲染。可以拆分 Context、稳定 value,或使用更适合选择性订阅的 store;但这属于性能和边界设计,不会改变“唯一权威来源”的原则。

7.2 Reducer 适合显式建模状态转换

当状态转换具有多个动作和约束时,useReducer 能把转换集中起来:

type State = {
  status: "idle" | "saving" | "success" | "error";
  message?: string;
};

type Action =
  | { type: "save_started" }
  | { type: "save_succeeded" }
  | { type: "save_failed"; message: string };

Reducer 应是纯函数:

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "save_started":
      return { status: "saving" };

    case "save_succeeded":
      return { status: "success" };

    case "save_failed":
      return { status: "error", message: action.message };

    default:
      return state;
  }
}

状态机的合法路径更明确:

stateDiagram-v2
    [*] --> idle
    idle --> saving: save_started
    saving --> success: save_succeeded
    saving --> error: save_failed
    error --> saving: save_started
    success --> saving: save_started

Reducer 不应直接发请求、写 DOM 或修改外部变量。副作用应在事件处理函数、Effect 或专门的数据层中执行,再通过 action 把结果送回 reducer。

7.3 什么时候需要外部 Store

如果状态需要:

  • 多个不相邻的 React 子树访问;
  • React 外部的代码也订阅;
  • 跨页面或跨模块持久化;
  • 精细选择订阅,避免无关组件重渲染;

可以使用外部 store。React 通过 useSyncExternalStore 接入这类 store,并要求提供订阅函数和读取快照函数。

外部 store 仍然需要自己的所有权模型。把数据放进 store 不等于完成了建模;如果组件、缓存和 store 各保存一份同样的用户对象,重复事实仍然存在。


八、不可变更新:归一化和所有权成立的基础

React 比较对象时通常依赖引用变化来判断是否需要更新。更新嵌套数据时,不能直接修改已有对象:

// 错误:直接修改旧对象
state.users["u-1"].name = "新名字";
setState(state);

这里 state 的顶层引用没有变化,依赖引用比较的组件可能无法正确发现更新;同时,旧渲染快照也被破坏。

应当沿着修改路径创建新对象:

setState((previous) => ({
  ...previous,
  users: {
    ...previous.users,
    "u-1": {
      ...previous.users["u-1"],
      name: "新名字",
    },
  },
}));

更新数组中的对象也是如此:

setTodos((previous) =>
  previous.map((todo) =>
    todo.id === id ? { ...todo, done: !todo.done } : todo,
  ),
);

对于依赖前一个值的更新,应使用函数式更新:

setCount((count) => count + 1);
setCount((count) => count + 1);

这样两个更新会基于更新队列中的前一个结果依次计算,最终增加 2。下面的写法可能两次都读取同一个渲染快照:

setCount(count + 1);
setCount(count + 1);

在 React 的批处理和并发渲染模型下,不能把 state setter 当作同步赋值语句。


九、客户端与服务端边界

React Server Components 和现代框架会把组件分为服务端组件与客户端组件。这里必须把“状态所有权”放到正确的执行环境中理解。

9.1 服务端组件不能拥有浏览器交互 state

服务端组件可以读取服务端数据并生成 UI,但不能使用依赖浏览器交互的 useStateuseEffect 等客户端能力。以支持该模型的框架为例,文件通常通过 "use client" 声明客户端边界:

// ProductPage.tsx:服务端组件示意
import ProductPicker from "./ProductPicker";

export default async function ProductPage() {
  const products = await loadProducts();

  return <ProductPicker products={products} />;
}
// ProductPicker.tsx
"use client";

import { useState } from "react";

export default function ProductPicker({
  products,
}: {
  products: { id: string; name: string }[];
}) {
  const [selectedId, setSelectedId] = useState<string | null>(null);

  return (
    <select
      value={selectedId ?? ""}
      onChange={(event) => setSelectedId(event.target.value || null)}
    >
      <option value="">请选择</option>
      {products.map((product) => (
        <option key={product.id} value={product.id}>
          {product.name}
        </option>
      ))}
    </select>
  );
}

这里的边界是:

  • 服务端负责获取初始产品目录;
  • 客户端负责选择交互;
  • products 是跨边界传入的数据;
  • selectedId 只属于客户端交互 state。

具体文件约定和可序列化数据限制由框架实现决定,不能把任意函数、类实例或非序列化对象作为跨边界 props 传递。

9.2 URL、服务端和客户端草稿不能互相冒充

例如筛选条件可能有三种不同所有权:

  • 可分享的筛选条件:URL 是权威来源;
  • 当前页面临时输入:客户端 state 是权威来源;
  • 已保存的筛选条件:服务端数据是权威来源。

如果三者都保存一份 filter,就必须定义同步冲突:

URL 改变 -> 是否覆盖本地草稿?
服务端响应返回 -> 是否覆盖正在编辑的输入?
用户提交失败 -> 保留哪份值?

没有明确规则时,最常见的失败表现是:

  • 返回上一页后筛选条件消失;
  • 浏览器前进后 UI 没有更新;
  • 请求返回较慢时覆盖用户新输入;
  • 服务端保存成功但本地仍显示旧值。

状态建模首先要确定权威来源,然后再决定 React state 是事实、草稿还是缓存。


十、异步更新与并发下的故障路径

10.1 请求结果可能过期

搜索框是典型例子。用户依次输入 rrerea,请求响应顺序可能不是发送顺序:

发送 r   ─────────────── 返回 r
发送 re  ───── 返回 re
发送 rea ─────────────── 返回 rea

如果不处理,旧请求可能在新请求之后返回并覆盖结果。

一种基本方案是使用 AbortController,并在 Effect 清理时取消旧请求:

function SearchResults({ query }: { query: string }) {
  const [state, setState] = useState<{
    status: "idle" | "loading" | "success" | "error";
    items: string[];
    error?: string;
  }>({
    status: "idle",
    items: [],
  });

  useEffect(() => {
    if (!query.trim()) {
      setState({ status: "idle", items: [] });
      return;
    }

    const controller = new AbortController();

    setState((previous) => ({
      ...previous,
      status: "loading",
      error: undefined,
    }));

    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 { items: string[] };
      })
      .then((data) => {
        setState({
          status: "success",
          items: data.items,
        });
      })
      .catch((error: unknown) => {
        if (error instanceof DOMException && error.name === "AbortError") {
          return;
        }

        setState({
          status: "error",
          items: [],
          error: error instanceof Error ? error.message : "未知错误",
        });
      });

    return () => controller.abort();
  }, [query]);

  if (state.status === "loading") return <p>加载中……</p>;
  if (state.status === "error") return <p>失败:{state.error}</p>;

  return (
    <ul>
      {state.items.map((item) => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
}

这段代码中的 statusitemserror 代表请求状态机,而不是三个完全独立的布尔值。它避免了这种不合法组合:

{
  isLoading: false,
  isError: true,
  isSuccess: true
}

如果请求库无法取消请求,也可以为每次请求生成序号,只接受最新序号的结果。核心不是具体 API,而是“过期结果不能取得所有权”。

10.2 Effect 依赖代表同步关系

Effect 的依赖数组不是“让代码运行一次”的装饰,而是声明:

当这些响应式输入变化时,重新建立与外部系统的同步。

如果 Effect 中读取了 query,却不把它纳入依赖,就可能使用闭包中的旧值。反过来,把不稳定的对象或函数放入依赖,又可能造成重复同步。应先明确 Effect 同步的外部资源,再根据实际读取关系建立依赖。

纯派生逻辑则不应放入 Effect:

// 不必要的 Effect
useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

应直接计算:

const fullName = `${firstName} ${lastName}`;

十一、常见失败模型及诊断方法

11.1 失败模型:同一事实在多个层级重复保存

表现:

  • 父组件显示“已选中”;
  • 子组件显示“未选中”;
  • 点击后某些区域更新,另一些不更新。

诊断方法:

  1. 搜索同一个业务概念的多个 useState
  2. 检查它们是否能表示不同生命周期;
  3. 如果不能,保留一个所有者;
  4. 通过 props 和事件回调连接其他组件。

11.2 失败模型:派生 state 延迟一个渲染

表现:

  • 列表变化后计数器短暂显示旧值;
  • 输入框更新后摘要下一帧才变化;
  • 测试需要等待一次额外更新。

诊断方法:

  1. 检查是否有 Effect 只做 setX(f(y))
  2. x 完全由 y 决定,删除 x state;
  3. 在渲染中直接计算;
  4. 只有计算确实昂贵时才考虑 useMemo

11.3 失败模型:列表按索引使用 key

items.map((item, index) => <Row key={index} item={item} />);

当列表插入、删除或排序时,索引可能指向另一个实体。若 Row 内部有输入草稿、焦点或其他 state,状态可能“跳到”错误的行。

应使用稳定且代表实体身份的 key:

items.map((item) => <Row key={item.id} item={item} />);

key 不只是消除警告,它参与 React 判断组件身份和 state 是否保留。

11.4 失败模型:把所有数据放进全局 store

表现:

  • 任何局部输入都触发大量组件更新;
  • 组件难以知道数据来自哪里;
  • 清理页面后仍残留旧状态;
  • 服务端缓存、本地草稿和 UI 状态混在一起。

诊断时可以给每个字段标注:

字段:searchQuery
权威来源:当前页面 URL / 本地草稿 / 全局 store?
生命周期:组件挂载 / 路由 / 会话 / 持久化?
修改者:输入框 / 服务端响应 / 定时器?
是否可由其他字段推出?

无法回答这些问题的字段,通常还没有完成建模。

11.5 失败模型:把对象引用稳定误认为数据不变

为了性能,有些代码会依赖引用不变:

const selected = products.find((product) => product.id === selectedId);

如果外部代码直接修改 products 中的对象,却保持数组引用不变,React 和 useMemo 都可能继续看到旧假设。不可变数据不只是 React 风格,而是让“引用变化代表可能有数据变化”这一约定成立的基础。


十二、一个可执行的建模检查过程

面对一个新组件,可以按以下顺序推导,而不是先写 useState

第一步:列出用户和外部系统能直接改变的事实

例如商品页:

selectedProductId
quantity
isPanelOpen

第二步:删除能够计算出的字段

selectedProduct = products[selectedProductId]
lineTotal = selectedProduct.price * quantity
canSubmit = selectedProductId !== null && quantity > 0

这些不是独立 state。

第三步:识别重复实体

如果多个订单行引用同一个商品,不要在每行都复制完整商品对象;保存 productId,商品目录由其权威数据源管理。

第四步:指定唯一所有者

商品目录:服务端数据层
选中的商品:ProductPage
输入草稿:ProductForm
提交状态:提交动作所在的表单或数据层

第五步:列出合法状态转换

无选择 -> 选择商品
选择商品 -> 修改数量
选择商品 -> 提交中
提交中 -> 成功
提交中 -> 失败

如果某些状态组合不合法,就优先用联合类型或 reducer 建模,而不是堆叠互相矛盾的布尔值。

第六步:检查故障和边界

至少回答:

  • 商品被删除时,selectedProductId 怎么处理?
  • 请求返回时,用户是否可能已经改了输入?
  • 路由变化时,草稿保留还是重置?
  • 服务端数据更新后,本地修改如何合并?
  • 组件卸载后,异步结果是否仍会影响状态?
  • 空数组、重复 ID、缺失引用和非法数量如何处理?

这些问题不是实现细节,而是状态模型的一部分。


结语:状态模型的核心是减少必须维护的不变量

一个可靠的 React 状态模型通常具有以下结构:

  • 最小状态保存不能从其他输入推出的事实;
  • 派生值通过纯函数从事实计算,不用额外 state 复制;
  • 归一化让共享实体只保存一份,通过 ID 表达关系;
  • 所有权明确谁保存事实、谁发起更新、谁只是读取;
  • 不可变更新让快照、引用比较和组件身份保持可预测;
  • 客户端、服务端、URL 和缓存各自承担清晰的系统边界。

状态越多并不一定越复杂,真正危险的是同一个事实有多个权威来源。只要能从数据流中指出“这个值在哪里产生、谁可以改变、哪些值由它推出、过期结果如何失效”,组件就不再只是若干 Hook 的集合,而是一个具有明确不变量和状态转换的系统。


系列导航与关联阅读

官方资料

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