不需要 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 两种排序:
- 排序结果由
visibleProducts派生,不能新增保存结果的 state 或 Effect。 - 不得修改原数组;库存相同按名称升序。
- 空查询加“仅库存”时顺序仍符合模式。
- 显示
first: <商品名>;无结果显示first: none。
完成后对照答案 04-01。
下一篇
下一篇把真正的外部同步放进 Effect,并观察清理如何阻止旧响应覆盖新结果。