React 状态与 Props:单向数据流、更新队列和状态提升
本文目录
本文保留旧笔记的归档日期,按现代 React 重写。组件通过 props 接收输入,通过 state 表达自己拥有、会随交互变化的数据;数据从上往下传,变化通过事件回调往上反馈。
Props:只读输入与事件回调
父组件决定传入什么 props,子组件把它们当作只读值。子组件要请求父级修改数据时,调用父级提供的回调,不要直接改 props:
function NameEditor({ name, onNameChange }) {
return (
<button type="button" onClick={() => onNameChange("新名字")}>
当前名称:{name}
</button>
);
}
function ProfilePage() {
const [name, setName] = useState("Haoge");
return <NameEditor name={name} onNameChange={setName} />;
}
Props 可以是字符串、数字、对象、函数、React 节点等。组件收到一个对象,并不意味着它可以原地修改这个对象;对象和数组的不可变更新规则同样适用。
State:由组件拥有的可变界面数据
函数组件使用 useState,类组件可使用 this.state 和 this.setState。两种写法都应把 state 看作不可变快照:更新会安排下一次 render,不会同步改写当前函数或方法里已经读取的变量。
函数组件与函数式 updater
如果新值依赖旧值,使用 updater 函数。连续更新时 React 会按队列计算:
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
function addThree() {
setCount((current) => current + 1);
setCount((current) => current + 1);
setCount((current) => current + 1);
}
return <button onClick={addThree}>当前值:{count}</button>;
}
如果写三次 setCount(count + 1),三次表达式都基于当前 render 的同一个 count 快照,未必得到预期的加三。不要把 setter 当作返回 Promise 的“同步/异步开关”,也不要在调用后立刻读取当前闭包并期待它已经变化。
对象和数组按不可变方式更新
setProfile((current) => ({
...current,
displayName: "新名称",
}));
setItems((current) => current.filter((item) => item.id !== removedId));
展开只复制一层。嵌套字段要沿修改路径逐层复制,或使用项目已有的不可变更新工具。直接修改原对象再把同一引用传回去,会让状态变化难以追踪,也可能让浅比较优化漏掉更新。
类组件 setState 的兼容知识
维护旧类组件时,setState 的对象参数只浅合并 state 顶层字段;依赖旧 state 时使用 updater。可选 callback 在更新提交后执行,但更新本身仍由 React 调度:
class Counter extends React.Component {
state = { count: 0 };
add = () => {
this.setState(
(current) => ({ count: current.count + 1 }),
() => console.log("已提交", this.state.count),
);
};
render() {
return <button onClick={this.add}>当前值:{this.state.count}</button>;
}
}
类组件依然受支持,但新组件通常优先使用函数组件。不要直接执行 this.state.count += 1,也不要用 forceUpdate() 掩盖状态没有正确更新的问题。
状态放在哪里:最小所有者与状态提升
状态只应放在真正需要拥有它的组件中:
- 一个组件独自使用的展开/折叠、输入草稿,优先放在该组件内部。
- 多个兄弟组件要共同读取或修改同一值,把它提升到最近共同父组件。
- 跨很多层需要读取稳定配置时,再考虑 Context。
- URL 可表达且需要分享/刷新保留的筛选或分页状态,优先写入 search params。
- 服务端数据与客户端交互状态要区分,列表缓存不一定应该复制进组件 state。
状态提升示意:
function ProductPage({ products }) {
const [selectedId, setSelectedId] = useState(products[0]?.id ?? null);
const selected = products.find((product) => product.id === selectedId);
return (
<>
<ProductList
products={products}
selectedId={selectedId}
onSelect={setSelectedId}
/>
<ProductDetails product={selected} />
</>
);
}
ProductList 和 ProductDetails 不各自维护一份 selectedProduct;父组件持有唯一的 selectedId,再把需要的值和回调传下去。
避免重复状态与不必要 Effect
以下值通常由 render 直接计算,不要为了“同步”再创建一份 state:
const completedCount = todos.filter((todo) => todo.completed).length;
const visibleTodos = todos.filter((todo) => matches(todo, filter));
将 props 复制到 state 会制造两个数据源:父级更新 props 后,组件 state 可能仍保留旧副本。若要对某个实体切换时完全重置子树状态,可以给子组件设置有业务含义的 key;这会重建该子树并丢弃未保存状态,必须符合交互预期。
交互产生的更新放在对应事件处理器中。只有需要把 React 状态与订阅、计时器、浏览器 API 或外部控件同步时,才使用 Effect。Hooks 与 cleanup 见 React Hooks 指南;受控表单见 React 渲染与表单。
回归检查
- 多次连续递增时,状态更新是否基于 updater 队列?
- 表单取消、重置、保存和失败后,状态所有者是否明确?
- 删除、排序或插入列表项后,key 是否仍对应同一业务实体?
- 父组件更新 props 后,子组件是否存在过期的 props 副本?
- 两个组件是否在维护同一份可变数据?能否提升到共同父级?
- 修改对象/数组后是否创建新引用,没有原地 mutation?
- 只有用户事件能决定的动作是否留在事件处理器,而非绕路放进 Effect?
- key 改变导致的子树重置是否会丢失用户草稿?
官方资料
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。