React 组件复杂起来,往往不是因为 JSX 太多,而是同一份信息被存了两次:列表存一份,筛选结果再存一份;选中 ID 存一份,选中对象又存一份。每次更新都要维护同步关系,遗漏一次就会出现“数据变了,画面没变”。本文以 React 函数组件和 Hooks 为范围,从一个搜索列表拆开这些问题。

先分清原始状态和计算结果

搜索框的 query、服务端返回的 items、用户选择的 selectedId 是独立信息,值得保存。filteredItems 与 selectedItem 可以从它们计算,通常不需要另一组 useState。

const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const visibleItems = items.filter(item => item.name.includes(query));
const selectedItem = items.find(item => item.id === selectedId) ?? null;

计算默认放在渲染过程。只有测量后发现筛选昂贵,或者确实需要稳定引用,才考虑 useMemo。缓存是性能手段,不应承担业务正确性。

事件表达意图,Effect 同步外部系统

点击提交时保存表单,直接写在事件处理函数里;不要先设置 submitted,再用 Effect 观察它并发请求。后者把一次操作拆成两处,使重复触发和错误反馈更难追踪。

Effect 适合订阅、定时器、第三方控件以及随查询参数变化的数据同步。每个 Effect 都应该能回答:它与哪个外部系统同步,依赖什么,结束时怎样清理?不要通过删除依赖项让警告消失,这会留下读取旧值的闭包。

请求顺序不等于返回顺序

用户先搜 A,再搜 AB,A 的请求可能最后返回。下面是浏览器侧示意,假定项目提供同源搜索接口;它展示清理和失效保护,不是完整的数据缓存层。

useEffect(() => {
  const controller = new AbortController();
  let active = true;
  setLoading(true);
  setError(null);
  fetch(`/api/search?q=${encodeURIComponent(query)}`, {
    signal: controller.signal,
  })
    .then(response => {
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return response.json();
    })
    .then(data => { if (active) setItems(data.items); })
    .catch(error => {
      if (active) setError(String(error));
    })
    .finally(() => { if (active) setLoading(false); });
  return () => {
    active = false;
    controller.abort();
  };
}, [query]);

实际项目还需要校验响应结构,按场景加入防抖和缓存。取消请求主要节省资源,active 用来阻止失效结果写入状态;取消不意味着服务器一定停止了处理。需要服务端渲染时,优先评估框架的数据加载能力,而不是把所有请求都移到 Effect。

状态放到真正拥有它的地方

弹窗是否打开通常属于页面局部状态;多个兄弟组件共同编辑的筛选条件提升到最近的共同父级;跨页面身份和偏好才考虑上下文或状态库。服务端数据还包含过期、重试和去重问题,不能只把它看作全局变量。

如果切换记录时需要清空整个表单,可以用记录 ID 作为 key 明确重建;如果希望保留草稿,就显式维护按 ID 索引的草稿。不要用数组下标标识可排序列表中的有状态组件。

用行为检查设计

连续快速搜索、切换选中项、关闭后重开、请求失败后重试,这四组操作比检查调用了多少次 setter 更有价值。开发环境 Strict Mode 的额外执行也可以帮助发现清理缺失,不要简单关闭它来掩盖重复订阅。

继续阅读:Vue 组合式函数前端交付工程化

参考资料