React 中的状态派生模式:为什么你不应该用 useEffect 同步状态

本文深入分析 React 中常见的反模式:使用 useEffect 同步状态。你会理解状态派生的核心概念、useEffect 同步状态带来的额外渲染和状态不一致问题,以及如何通过渲染期间派生、key 重置和 render 期间调整 state 来正确实现,并明确哪些场景仍然需要 useEffect。

从 useState 到 useEffect:一个很自然的错误

很多 React 开发者刚接触到 useEffect 时,会把它当作一个“状态监听器”,觉得只要某个状态变了,就应该通过 effect 去更新另一个状态。这听起来很直觉,但真这么写之后,应用很快就出现一些奇怪的问题:界面多渲染一次,或者 UI 显示的内容总是慢一拍。这篇文章想聊一个更基础的问题:那些从现有状态计算出来的值,到底需不需要放进 state 里?如果你正打算用 useEffect 去同步它们,我建议你先停下来。

AI technology illustration

想象一个用户管理后台,你点击“编辑用户”按钮,弹窗里的表单却会短暂显示上一个用户的信息。排查半天,发现就是因为 useEffect 在 props 变化后异步重置了表单。这种“闪一下”的问题在慢设备上尤其明显,用户会下意识以为表单数据错乱了。很多团队最初遇到这个问题,都会靠增加 loading 状态去掩盖,但根治的方法其实更简单。

派生状态到底是什么

“从现有状态计算出来的值”,在 React 社区里通常叫做派生状态(derived state)。比如列表经过筛选后的结果、购物车的总价、表单是否填写完整,这些都是由已有数据直接算出来的。它们并没有独立的业务语义,不应该单独维护一份 state。

判断一个值是不是派生状态,只需要问一个问题:如果删掉这个 state,我能不能在渲染时用其他 state 或 props 把它算出来?如果能,它就不属于最小状态集合。

为什么 useEffect 同步状态会慢一拍

先搞清楚 useEffect 的执行时机。React 的一次更新流程大致是:渲染组件、提交 DOM、浏览器绘制,然后才执行 effect。也就是说,你在 effect 里调用 setState,是在绘制之后才触发的,这会立刻再安排一次渲染。于是画面就先展示了旧值,再被新值覆盖。如果两个状态之间没有同步,用户可能看到一帧不期望的 UI。

这还不是最糟糕的。当你用 effect 同步多个状态时,很容易出现“连锁反应”:A 变了,effect 同步 B,B 变了又触发另一个 effect 同步 C。依赖数组稍微漏写一个,就会出现状态永远追不上的情况。调试这类问题往往要追着执行顺序走,非常痛苦。

反模式一:用 useEffect 计算派生值

很多人会这样写:

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

useEffect(() => {
  setFullName(`${firstName} ${lastName}`)
}, [firstName, lastName])

这个例子看似合理,但实际会带来至少两个问题。

第一,组件会额外渲染一次。首次 render 时 fullName 还是旧值,effect 在浏览器绘制之后才执行,然后 setFullName 触发第二次渲染。对于这个简单的场景,用户可能感受不到,但在更复杂的组件里,这会导致子组件树被无意义地重复协调。

第二,逻辑的可维护性变差。如果以后有人在某个事件里直接 setFullName(‘自定义名字’),这个值会一直保持到 firstName 或 lastName 变化为止。而一旦 firstName 变化,effect 又会把它覆盖掉。你很难说清楚这个值到底是什么时候由谁决定的。

其实这个场景根本不需要 state:

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

每次渲染时重新计算,赋值给一个常量,直接使用。没有额外渲染,没有同步问题,代码量也更少。也许有人会说,如果 fullName 被多个地方使用,每次都计算不是重复吗?其实重复计算只发生在组件函数执行时,而 React 每次渲染本来就会执行全部代码,所以派生变量天然是每渲染一次计算一次。这通常比维护一个同步逻辑更廉价。

反模式二:用 useEffect 响应 props 重置状态

比上面更常见的反模式,是用 useEffect 去响应 props 变化,然后重置组件内部状态。最典型的就是“根据 userId 加载用户信息并重置表单”。

很多团队会这样写:

const UserForm = ({ userId }) => {
  const [name, setName] = useState('')

  useEffect(() => {
    setName('')
  }, [userId])

  // ...
}

这个写法能够工作,但同样的问题:当 userId 变化时,第一次渲染仍然带着旧表单数据,effect 执行后才把 name 清空。用户的肉眼可能看不出中间状态,但如果你在表单里做了受控输入,状态并不是立即同步的,你甚至可能遇到“输入框值闪一下”的诡异现象。尤其当 userId 从 A 切到 B,又切回 A 时,你还需要额外记忆上一个值,否则 effect 根本不会触发。

更好的做法是让 React 组件自己意识到“key 变了,重新挂载”。使用 key 属性:

<UserForm key={userId} userId={userId} />

当 userId 变化时,这个组件会被完全卸载再重新挂载,所有 useState 都会重新初始化。这对“整个表单都需要重置”的场景非常合适,逻辑上更干净,也不需要维护 effect。

render 期间调整 state:官方推荐的替代方案

但有些时候,你希望保留部分状态,而不是全部重置。比如一个复杂的筛选面板,切换项目时希望保留展开的面板,但清空搜索关键词。这种“部分调整”的需求,React 官方提供了一种叫做“render 期间调整 state”的模式:

const [prevProjectId, setPrevProjectId] = useState(projectId)
const [search, setSearch] = useState('')

if (prevProjectId !== projectId) {
  setPrevProjectId(projectId)
  setSearch('')
}

在渲染过程中直接调用 setState,React 会立即重新执行组件函数,但不会提交中间状态,也不会额外触发子组件渲染。这是被官方文档明确承认的做法。注意,这里不能在条件外面调用 setState,否则会造成无限循环。这段代码的核心是:先记录一个“上一个 props 值”,当 props 改变时,同时更新记录值和你希望重置的状态。

派生状态计算成本高时该怎么办

既然建议用普通变量派生,就总有人担心性能。确实,有些派生,比如对万级数组做多次 filter 和 map,每次渲染都做可能会造成可感知的卡顿。

但解决问题的方式不是把派生结果塞进 state,而是用 useMemo 缓存计算过程:

const visibleItems = useMemo(() => {
  return items.filter(item => item.visible)
}, [items])

useMemo 仍然是在渲染期间完成派生,只不过它在依赖不变时跳过计算。这比用 useEffect 同步到 state 更干净,因为它在渲染阶段就能得到正确结果,不产生中间状态。注意,useMemo 本身也有开销,依赖数组维护不好反而容易引入 bug。简单场景不用它,复杂计算再用。

三种方案应该怎么选

这里把上面提到的方案放在一起对比:

方案 实现方式 同步时机 额外渲染 适合场景
渲染期间派生 直接计算变量 浏览器绘制前 值完全可由现有 state 或 props 推导
key 重置 用 key 控制组件挂载 组件挂载时 整个组件状态需要完全重置
render 期间调整 state 条件调用 setState 渲染时 有,但可控(至多一次) 需要保留部分状态,响应 props 变化
useEffect 同步 在 effect 中 setState 绘制之后 有,且容易级联 真正与外部系统同步,如订阅、网络

注意,表格中的“render 期间调整 state”看起来也有一次额外渲染,但它是在提交前完成的,不像 effect 需要把中间状态提交到 UI。所以用户体验上的开销更小,也更容易推理。

什么时候才该用 useEffect

说了这么多,并不是让你完全抛弃 useEffect。它的真正用途是同步外部系统,比如网络请求、浏览器事件、WebSocket 订阅、操作 DOM、日志上报等。这些操作不能在渲染期间执行,因为渲染应该是纯函数,不能有副作用。

举个例子,订阅一个实时消息通道:

useEffect(() => {
  const subscription = chatAPI.subscribe((message) => {
    setMessages((prev) => [...prev, message])
  })
  return () => subscription.unsubscribe()
}, [])

如果你没有这种外部同步需求,其实大多数 useEffect 都不是必需的。很多由 useEffect 引发的 bug,本质上是不小心把“事件处理逻辑”放进了 effect 里。事件应该由 onClick、onChange 这些回调直接处理,而不是派发到 effect 中去监听。判断标准很简单:这个操作是不是必须发生在渲染之后?比如请求数据,你需要避免每次渲染都发请求,所以用 effect 配合依赖数组;又比如手动操作 DOM,必须在 DOM 提交后执行。如果没有这些要求,就不要用 effect。

三个容易踩的误区

  • 认为“所有状态变化都必须放进 useState”。实际上 React 的 state 应该是一个最小集合,能够推导出的值都不要保留。你保留的 state 越多,组件越难维护,也越容易遭遇不同状态之间不同步的坑。
  • 认为“useEffect 可以解决状态不同步”。恰恰相反,useEffect 通常会让状态同步问题变得更隐蔽。因为它把更新时机推迟到渲染之后,你本来希望通过它让状态立刻一致,结果却引入了一个中间状态,还额外增加了一次渲染开销。
  • 认为“每次渲染都重新计算派生值会伤害性能”。很多派生值的计算量非常小,比如数组 filter、字符串拼接、简单对象计算。而 setState 导致的一次额外渲染会递归触发整个子组件树的协调,这个开销通常比直接计算大得多。只有当你真的遇到性能瓶颈,并且计算量确实很大,才需要考虑 useMemo。而且 useMemo 也不是免费午餐,它本身也有 memory 开销和依赖维护成本。

落地建议

如果你正在重构一个被 useEffect 搞得很乱的状态逻辑,可以按这个顺序来:

  1. 梳理出哪些 state 是真正独立的。独立的条件是“不能从其他 state 或 props 计算出来”。凡是能算出来的,直接删掉,在渲染时定义变量。
  2. 对于父级 props 变化需要重置内部状态的情况,先考虑是否能把状态提升或降级。如果整个组件视图完全由外部 props 决定,直接用 key 重置是最简单的。
  3. 如果确实需要部分重置,使用 render 期间调整 state 的方法。记住,这个调用必须放在条件判断里,并且要同步更新记录的 prev 值,否则会无限循环。
  4. 最后,剩余的真正的副作用,比如异步请求、订阅、手动操作 DOM,再交给 useEffect,并且谨慎填写依赖数组。

在实际项目中,这类重构往往能让状态流程图变得清晰很多。你会发现组件要管理的 state 数量变少了,测试也更好写,因为很多值不再依赖“某个 effect 在某个时间点执行过”。

小结

状态派生模式的核心,是让组件只保存最小必要状态,所有从这些状态计算出来的值都在渲染时同步产生。这样既避免了多余渲染,也消除了数据不一致的土壤。

当你下次想写“useEffect 里去同步另一个 state”的时候,先停一下:这个新 state 真的是独立的吗?能不能直接计算?能不能用 key?能不能在渲染期间调整?大多数情况下,上面某一招会给出更简单的答案。useEffect 留给真正的外部世界就好。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/714/

(0)
上一篇 14小时前
下一篇 6小时前

相关推荐