3、状态提升与下放:组件间共享状态、状态提升的原则、状态下放的时机

这一章,我们来聊聊 React 里一个绕不开的话题——状态该放哪。

我刚带团队的时候,经常看到新人把状态满天飞。A 组件需要的数据,放在 B 组件里;B 组件要改的东西,又藏在 C 组件深处。最后调试起来,跟破案似的。说白了,状态管理混乱的根源,往往就是「提升」和「下放」这两个动作没做好。

3.1 组件间共享状态:为什么不能各管各的?

先看一个最简单的场景。两个兄弟组件,一个输入框,一个展示区。输入框改了内容,展示区要同步显示。

你可能会想:各自维护自己的 state 不就行了?

嗯,试试看。

// ❌ 错误示范:各自为政
function InputBox() {
  const [text, setText] = useState('');
  return <input value={text} onChange={e => setText(e.target.value)} />;
}

function DisplayBox() {
  // 这里拿不到 InputBox 里的 text
  return <p>当前输入:???</p>;
}

DisplayBox 根本不知道 InputBox 里发生了什么。这就是典型的「状态孤岛」问题。两个组件需要共享同一份数据,但数据被锁在了各自的闭包里。

解决办法?把状态提到它们共同的父组件里去。这就是状态提升。

3.2 状态提升的原则:什么时候该提?提到哪?

我个人习惯用一个简单的判断标准:两个或以上的组件需要读写同一份数据时,这份数据就不该属于其中任何一个。

提到哪?提到它们最近的共同祖先组件里。

核心原则:状态提升的目标是「最近公共祖先」。不是越往上越好,也不是提到全局 store 就万事大吉。

来看一个实际例子。一个搜索页面,有搜索框、搜索结果列表、分页器。这三个组件都需要知道当前的关键词和页码。

// ✅ 正确做法:状态提升到父组件 SearchPage
function SearchPage() {
  const [keyword, setKeyword] = useState('');
  const [page, setPage] = useState(1);

  return (
    <div>
      <SearchBox keyword={keyword} onSearch={setKeyword} />
      <ResultList keyword={keyword} page={page} />
      <Pagination page={page} onPageChange={setPage} />
    </div>
  );
}

这里 keyword 和 page 被提升到了 SearchPage。三个子组件通过 props 读取和修改它们。数据流清晰,谁改了什么一目了然。

我曾经在一个项目中看到有人把 keyword 提到了顶层的 App 组件里。整个应用就一个搜索功能,结果所有组件都跟着重渲染。没必要。提到 SearchPage 就够了。

小技巧:提升状态时,顺便想想「这个状态的生命周期和哪个组件一致」。keyword 只在 SearchPage 范围内有意义,那就放 SearchPage。别往全局 store 里塞。

3.3 状态下放的时机:什么时候该把状态推回去?

状态提升是常态,但有时候也需要「下放」。下放,就是把状态从父组件移回子组件,或者拆成更小的状态单元。

什么时候该下放?我总结了三个信号:

  1. 父组件因为无关状态频繁重渲染——子组件明明没变,父组件一更新,它也跟着 rerender。
  2. 状态只被一个子组件使用——那就别放在父组件里占地方。
  3. 状态变化频率差异大——高频变化的状态和低频变化的状态混在一起,互相拖累。

举个例子。一个表单页面,有用户基本信息(姓名、邮箱)和一个实时搜索的下拉框。搜索下拉框的输入变化非常频繁,但基本信息几乎不变。

// ❌ 状态过度提升:搜索关键词导致整个表单重渲染
function FormPage() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [searchQuery, setSearchQuery] = useState(''); // 这个状态变化太频繁

  return (
    <div>
      <BasicInfo name={name} email={email} />
      <SearchDropdown query={searchQuery} onQueryChange={setSearchQuery} />
    </div>
  );
}

每次 searchQuery 一变,FormPage 重新渲染,BasicInfo 也跟着 rerender。BasicInfo 根本没用到 searchQuery,白干活。

解决办法?把 searchQuery 下放到 SearchDropdown 内部。

// ✅ 状态下放:高频状态隔离到子组件
function SearchDropdown() {
  const [query, setQuery] = useState(''); // 自己管自己
  // ... 搜索逻辑
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

function FormPage() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');

  return (
    <div>
      <BasicInfo name={name} email={email} />
      <SearchDropdown />  // 不再传 query 相关 props
    </div>
  );
}

这样一来,searchQuery 的变化只影响 SearchDropdown 自己。FormPage 和 BasicInfo 完全不受干扰。

注意:下放的前提是「没有其他兄弟组件需要这份状态」。如果 SearchDropdown 的查询结果还要展示在页面的其他地方,那就不能简单下放。这时候可以考虑用 useMemo 或 React.memo 做优化,而不是强行下放。

3.4 提升与下放的动态平衡:一张图看懂

我画了一张流程图,帮你快速判断当前的状态该提升还是该下放。

状态提升与下放决策流程图 状态提升与下放决策流程 发现状态共享需求 状态是否被多个组件使用? 是 提升到最近公共祖先 父组件是否因无关状态频繁重渲染? 是 下放到子组件 否 保持现状 评估性能,持续迭代 提示:每次状态变更后,重新评估「是否还有共享需求」和「是否造成性能问题」

3.5 实战中的避坑指南

最后,分享几个我踩过的坑。

  • 过度提升:把只在一个子组件里用的状态,硬提到父组件甚至全局 store。结果就是「牵一发而动全身」。我曾经见过一个项目,一个按钮的 loading 状态放在 Redux 里,整个页面跟着闪。没必要。
  • 过早下放:一开始觉得「这个状态只有 A 组件用」,就放在 A 里。后来 B 组件也需要,又得提回来。来回折腾。我的建议是:先保守提升,等确定只有单个组件使用时再下放。
  • 忽略 context 的滥用:很多人觉得「提升太麻烦,直接用 Context 吧」。但 Context 不是银弹。它会让所有消费组件都重渲染。如果你只是两个兄弟组件共享状态,提升到父组件比用 Context 更轻量。

总结一句话:状态提升解决「共享」问题,状态下放解决「性能」问题。两者不是互斥的,而是动态平衡的。每次重构或加新功能时,都问自己一句:「这个状态,放对地方了吗?」