
一句话:状态应该放在「需要它的最近的公共祖先」——这就是「状态提升」;而「单一数据源」要求「同一个数据只有一个拥有者」,否则会出现不同步的 bug。
一、什么是状态提升
// ❌ 问题:两个子组件都需要「温度」这个值,但状态在各自内部
function CelsiusInput() {
const [temp, setTemp] = useState(0); // 各自一份
return setTemp(+e.target.value)} />;
}
function FahrenheitDisplay() {
const [temp] = useState(0); // 无法知道另一个组件的值
return {temp * 1.8 + 32};
}
// ✅ 状态提升:把状态提到公共父组件
function TemperatureConverter() {
const [celsius, setCelsius] = useState(0); // ⭐ 唯一数据源
return (
<>
>
);
}
function CelsiusInput({ celsius, onChange }: { celsius: number; onChange: (v: number) => void }) {
return onChange(Number(e.target.value))} />;
}
function FahrenheitDisplay({ celsius }: { celsius: number }) {
return {(celsius * 9) / 5 + 32}°F;
}
状态提升的三步:
① 找到「需要这个状态」的组件的【最近公共祖先】
② 把状态声明在那个祖先组件里
③ 把「值」和「修改方法」通过 props 传给需要的组件
本质:把「多处都需要的数据」的拥有者提升到它们的公共祖先,
让数据只有一份(单一数据源),避免不同步。
二、何时该提升(判断标准)
✅ 应该提升的信号:
① 两个及以上的兄弟组件需要同一个值
② 一个组件的状态会被另一个组件修改
③ 数据的「来源」与「使用者」不在同一个组件里
④ 需要在父组件里「读取/展示」子组件的状态
❌ 不该提升的信号:
① 只有当前组件自己用(保持在下层,避免父组件无谓重渲染)
② 提升后需要透传 5 层(说明该用 Context/store,而不是继续提升)
③ 是「纯 UI 状态」(如 hover、展开动画)→ 留在最近的组件里
// 只需要「一次性读取」时不需要提升
function Parent() {
const formRef = useRef(null);
const handleSubmit = () => {
const values = formRef.current?.getValues(); // ⭐ 需要时才读,不用提升状态
api.post('/submit', values);
};
return (
<>
>
);
}
三、提升的代价
① 父组件重渲染会带上整棵子树
状态提升到父组件后,父组件状态变化 → 父组件重新渲染 → 所有子组件重新渲染
(除非用 memo + 稳定 props)
② props 透传变多
中间层的组件「不需要但必须传」
③ 组件变得不那么「自包含」
子组件从「自己管状态」变成「受控组件」,需要父组件配合
④ 提升的位置过高会让「不相干的更新」互相影响
比如把「输入框的值」提到 App 层 → 每次输入都让整个应用重渲染
// ③ 的对比:非受控 → 受控的复杂度变化
// 非受控(自包含,但父组件拿不到值)
function SelfContained() {
const [value, setValue] = useState('');
return setValue(e.target.value)} />;
}
// 受控(父组件能拿到值,但需要传递 props)
function Controlled({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return onChange(e.target.value)} />;
}
四、状态下移(与提升相反的方向)
// ❌ 状态放得太高:所有子组件都跟着重渲染
function AppBad() {
const [query, setQuery] = useState(''); // 只有一个子组件用它
const [theme, setTheme] = useState('light');
return (
{/* 每次输入都让 App 重渲染 */}
{/* ⚠️ 被无谓地重渲染 */}
);
}
// ✅ 状态下移:放到真正需要它的地方
function AppGood() {
const [theme, setTheme] = useState('light');
return (
{/* ⭐ 输入状态在里面,不影响其它组件 */}
);
}
function SearchSection() {
const [query, setQuery] = useState(''); // 就近管理
return (
<>
setQuery(e.target.value)} />
{/* 只有它需要重渲染 */}
>
);
}
「状态提升」与「状态下移」是一对矛盾的取舍:
提升 → 便于共享,但父组件重渲染会带上更多子树
下移 → 渲染范围更小,但数据共享变得困难
判断标准:
「这个状态需要被其它组件读取/修改吗?」
├─ 需要 → 提升到「最近的公共祖先」(不要更高)
└─ 不需要 → 留在「最近的、需要它的组件」里
五、单一数据源(Single Source of Truth)
// ❌ 两份数据 → 必须手动同步 → 一定会不同步
function Bad() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState(''); // ⚠️ 冗余:能从上面算出来
const handleChange = (first: string, last: string) => {
setFirstName(first);
setLastName(last);
setFullName(`${first} ${last}`); // ⚠️ 手动同步,容易漏
};
}
// ✅ 单一数据源 + 派生值
function Good() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = `${firstName} ${lastName}`; // ⭐ 派生出来,不需要存
}
// ❌ 另一个常见反例:把 props 复制进 state
function BadChild({ user }: { user: User }) {
const [name, setName] = useState(user.name); // ⚠️ user 变化后不会同步
// 只能用 Effect「同步」→ 多一次渲染、且存在中间不一致
useEffect(() => {
setName(user.name);
}, [user.name]);
}
// ✅ 方案一:直接用 props(只读场景)
function GoodChild({ user }: { user: User }) {
return {user.name};
}
// ✅ 方案二:用 key 让组件在「数据源变化」时重建
// ✅ 方案三:确实需要「可就地编辑」时,明确区分「初始值」与「当前值」的语义
function EditableField({ initialValue, onCommit }: { initialValue: string; onCommit: (v: string) => void }) {
const [draft, setDraft] = useState(initialValue);
// 这里的 draft 是「编辑草稿」,与 initialValue 是【不同语义】的数据
return setDraft(e.target.value)} onBlur={() => onCommit(draft)} />;
}
六、常见的状态归属错误
// ① 状态放得太高(性能问题)
const [inputValue, setInputValue] = useState(''); // 在 App 里
// → 每次输入都让整个应用重渲染
// ✅ 下移到输入框所在的组件
// ② 状态放得太低(无法共享)
const [selectedId, setSelectedId] = useState(null); // 在列表项里
// → 另一个组件无法知道选中了谁
// ✅ 提升到列表的父组件
// ③ 同一份数据存在多处
const [list, setList] = useState([]);
const [count, setCount] = useState(0); // count = list.length
// ✅ 派生:const count = list.length;
// ④ 把「可以算出来的」存成 state
const [filtered, setFiltered] = useState([]);
useEffect(() => { setFiltered(list.filter(...)); }, [list]);
// ✅ 渲染时直接算(必要时 useMemo)
// ⑤ 把「URL 里能表达的」存成 state
const [page, setPage] = useState(1);
// ⚠️ 刷新后丢失、无法分享链接
// ✅ 用 useSearchParams 存到 URL
// ⑥ 把「服务端数据」存进全局 store
useStore.setState({ users: await api.getUsers() });
// ✅ 用 TanStack Query(它专门管缓存、失效、重试)
// ⑦ 把「只有一个组件用的」状态放进 Context
// → 白白增加了一层重渲染风险
| 状态类型 | 应该放在哪 |
|---|---|
| 只有自己用的 UI 状态(hover、展开) | 组件内部 useState |
| 兄弟共享 | 最近的公共父组件 |
| 跨多层、低频 | Context |
| 跨页面、高频、复杂 | 状态管理库 |
| 服务端数据 | TanStack Query / SWR |
| 可分享/可刷新保持的状态 | URL(useSearchParams) |
| 表单草稿(跨刷新) | localStorage 或后端草稿接口 |
七、常见坑
// ① 把状态提升到 App 层(什么都放全局)
// → 任何变化都让整棵树重渲染
// ② 状态提升后忘记传「修改方法」
function Child({ value }) {
return {}} />; // ❌ 输入无效
}
// ③ 提升后中间层忘记透传
function Middle({ value, onChange }) {
return ; // ❌ 忘了传 props
}
// ④ 把 props 复制进 state 再用 Effect 同步
useEffect(() => { setLocal(props.value); }, [props.value]); // ⚠️ 多一次渲染
// ⑤ 存「派生值」导致不同步
const [fullName, setFullName] = useState('');
useEffect(() => { setFullName(`${f} ${l}`); }, [f, l]); // ❌
// ⑥ 把「能算出来」的列表也存成 state
const [sorted, setSorted] = useState([]);
useEffect(() => { setSorted([...list].sort()); }, [list]); // ❌
// ⑦ 状态提升后没有用 memo → 所有子组件跟着重渲染
// ⑧ 用「兄弟组件互相传 ref」来通信(反模式)
// ⑨ 在多个地方各存一份「当前用户」(应该用 Context/store 单一来源)
// ⑩ 把「本应放 URL 的状态」放 state → 刷新丢失、不能分享
// ⑪ 提升到「公共祖先的祖先」(过度提升)
// ⑫ 用 Context 传「频繁变化的状态」→ 全量重渲染
八、面试延伸
- 「什么是状态提升?」
指「把多个组件需要共享的状态,移动到它们最近的公共祖先组件中」,然后把「值」与「修改方法」通过 props 分发下去。它的目的是保证「同一份数据只有一个拥有者」(单一数据源),从而避免「多个组件各存一份导致不同步」的 bug。经典例子是「摄氏温度输入框 + 华氏温度展示」——两者必须读同一个温度值。 -
「状态应该放在哪里?」
一条原则:放在「需要它的最近的公共祖先」——既不要放得过高(会导致大范围重渲染),也不要放得过低(无法共享)。具体的分配:只有自己用的 UI 状态放组件内部;兄弟共享放最近公共父组件;跨多层低频用 Context;跨页面高频用状态库;服务端数据用 TanStack Query;需要可分享/可刷新保持的放 URL。 -
「状态提升有什么代价?」
三个代价:① 父组件重渲染会带上更多子树——因为状态在父组件里,它的变化会让整棵子树重新渲染(除非配合memo与稳定 props);② props 透传变多——中间层「不需要但必须传」;③ 组件不再自包含——从「自己管状态」变成「受控组件」,需要父组件配合。所以「能用局部状态就别提升」,只在确实需要共享时才提升,且不要提升到比「最近公共祖先」更高的位置。 -
「什么是『状态下移』?什么时候该用?」
状态下移是「把状态放到真正使用它的最近组件里」,是状态提升的反向操作。当发现「一个状态只有某个子组件用,但被放在了高层」时(导致无关组件跟着重渲染),就应该把它下移。典型例子:把「搜索框的输入值」从 App 层下移到「搜索区域组件」里,这样每次输入只影响那个区域,而不是整个页面。 -
「什么是单一数据源?为什么重要?」
指「同一份数据只有一个拥有者/来源」。它的重要性在于:如果同一份数据存在多处(比如firstName/lastName与fullName同时存 state、或者父子组件各存一份「当前用户」),就必须手动同步——而手动同步一定会漏(某条路径忘了更新),导致界面显示不一致、难以排查。解法是:能算出来的就派生(不存)、能提升的就提升到唯一拥有者、服务端数据只用一处缓存。 -
「派生状态为什么不该存进 state?」
因为它是「能从其它 state/props 算出来的值」(如fullName、count = list.length、sorted = [...list].sort())。存进 state 会带来三个问题:① 需要额外的同步逻辑(通常用useEffect),而同步会多一次渲染、且存在「不一致的中间态」;② 数据冗余导致 bug(两个源不同步);③ 增加认知负担(读代码的人要判断「哪个是真相」)。正确做法是渲染时直接计算,开销大时用useMemo缓存。 -
「为什么不该把 props 复制进 state?」
因为这会产生第二份数据源:props是外部传入的、会变化的,而useState(props.x)只在首次渲染取值,之后props.x变化时 state 不会跟着变(除非写 Effect 同步,但那又多一次渲染且存在中间不一致)。正确做法有三:① 直接用 props(只读场景);② 用key强制重建组件(当「props 变化 = 换一个实例」时,如userId变化);③ 如果确实需要「可就地编辑」,就明确区分语义:把 state 当作「编辑草稿」,与「初始值」区分开(如onBlur时提交)。 -
「为什么说『服务端数据不该放全局 store』?」
因为服务端数据和客户端状态有本质差异:① 生命周期不同(需要缓存、失效、重新验证);② 可能过期(需要「什么时候重新请求」的策略);③ 需要去重(多个组件同时请求同一接口)。自己用 Redux/Zustand 管理这些会写大量样板代码,而 TanStack Query / SWR 专门解决这些问题(staleTime、gcTime、invalidateQueries、自动重试、乐观更新)。所以划分应该是「客户端状态用 store,服务端状态用查询库」。
一句话速记
状态放哪里?「需要它的最近的公共祖先」——放高了会让大范围重渲染(该下移就下移),放低了无法共享(该提升就提升);单向数据流 + 单一数据源是两条铁律:能算出来的派生值不要存 state(否则要手动同步、必不同步)、不要把 props 复制进 state(要用就用 props,或
key重建,或明确区分「草稿」语义);分配口诀是「UI 状态就地、兄弟共享提升、跨层低频 Context、高频跨页 store、服务端数据查询库、可分享状态放 URL」。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!