Skip to content

不需要 Effect 的情况 ​

问题:筛选结果应该再存一份吗 ​

商品列表、查询词和“仅看有库存”已经足以决定屏幕上有哪些商品。如果再创建 visibleProducts 状态,并在 Effect 中更新它,就会经历:先用旧结果渲染 → 提交 DOM → Effect 写入新结果 → 再渲染。两份可互相推导的状态还可能暂时不一致。

先把 Effect 分成三个不同问题:

  • 渲染计算:只依赖当前 props/state,直接在组件函数中计算。
  • 用户事件:因为这次点击或提交才发生,写在对应事件处理函数中。
  • 外部同步:组件显示期间要与网络连接、浏览器 API、计时器或非 React 控件保持一致,才使用 Effect。

完整实验:纯渲染筛选器 ​

将下面内容保存为 src/App.jsx。示例只使用 React 19 稳定 API。

jsx
import { useState } from 'react'

const initialProducts = [
  { id: 1, name: 'Mechanical Keyboard', stock: 3 },
  { id: 2, name: 'Monitor Arm', stock: 0 },
  { id: 3, name: 'USB-C Cable', stock: 8 },
]

export default function App() {
  const [products] = useState(initialProducts)
  const [query, setQuery] = useState('')
  const [onlyInStock, setOnlyInStock] = useState(false)
  const [audit, setAudit] = useState([])

  const normalizedQuery = query.trim().toLowerCase()
  const visibleProducts = products.filter((product) => {
    const matchesText = product.name.toLowerCase().includes(normalizedQuery)
    const matchesStock = !onlyInStock || product.stock > 0
    return matchesText && matchesStock
  })
  const totalStock = visibleProducts.reduce(
    (sum, product) => sum + product.stock,
    0,
  )

  function handleExport() {
    const names = visibleProducts.map((product) => product.name).join(', ')
    setAudit((entries) => [
      `导出 ${visibleProducts.length} 项:${names || '无'}`,
      ...entries,
    ])
  }

  return (
    <main>
      <h1>库存筛选</h1>
      <label>
        搜索
        <input value={query} onChange={(event) => setQuery(event.target.value)} />
      </label>
      <label>
        <input
          type="checkbox"
          checked={onlyInStock}
          onChange={(event) => setOnlyInStock(event.target.checked)}
        />
        仅看有库存
      </label>
      <p data-testid="summary">
        {visibleProducts.length} 项,合计库存 {totalStock}
      </p>
      <ul>
        {visibleProducts.map((product) => (
          <li key={product.id}>{product.name}({product.stock})</li>
        ))}
      </ul>
      <button onClick={handleExport}>记录本次导出</button>
      <ol>
        {audit.map((entry, index) => <li key={`${entry}-${index}`}>{entry}</li>)}
      </ol>
    </main>
  )
}

依次输入 arm、勾选库存开关,再输入 c。列表、项目数和库存合计来自同一份快照。点击导出则不同:导出记录是这次点击造成的,因此处理函数是原因最清楚的位置。

从过程得到规则 ​

一次输入会先更新 query,React 再调用组件。三个派生值在这次调用内依次算出,随后一起提交。它们不是需要长期记忆的信息,所以不属于 state。

错误写法会把派生结果存入 state,再由 Effect 调用 setter。它不仅多一次提交,还让结果有机会和输入脱节。若计算确实昂贵,应先测量,再考虑 useMemo;它是性能缓存,不是正确性所需的状态仓库。

关键变体 ​

  • 重置整个业务身份对应的子树,可改变子组件 key,无需用 Effect 逐个清 state。
  • 点击同时改变子、父状态,应在事件中一次调用回调,而非等子状态改变后再由 Effect 通知。
  • 结果必须持续匹配当前 URL/查询参数时,网络是外部系统,Effect 可以成立;生产框架若有 loader/缓存应优先使用。
  • 浏览器在线状态等外部可变源更适合 useSyncExternalStore。

边界:职责匹配 ​

计算期间不得写 localStorage、发请求或修改 DOM,因为渲染可能重试或被丢弃。事件也不能替代持续同步:若房间由 URL、父 props 或恢复会话改变,连接必须跟着当前房间变化。判断依据是外部系统是否需要和当前渲染保持一致。

练习:契约任务 ​

加入 sortMode,允许 name 与 stock-desc 两种排序:

  1. 排序结果由 visibleProducts 派生,不能新增保存结果的 state 或 Effect。
  2. 不得修改原数组;库存相同按名称升序。
  3. 空查询加“仅库存”时顺序仍符合模式。
  4. 显示 first: <商品名>;无结果显示 first: none。

完成后对照答案 04-01。

下一篇 ​

下一篇把真正的外部同步放进 Effect,并观察清理如何阻止旧响应覆盖新结果。

用心记录每一步成长