首页 / 文章

选择合适的 React 工具:Derive、Handle、Fetch、Defer 还是 Effect

一份决策指南,介绍如何用派生值、事件处理程序、数据层、useTransition、measured useMemo以及React 19 API来替代传统的useEffect调用。

发布者:REACTAPP.TOP阅读约 8 分钟1896 词

来源: Stop Using `useEffect` for Everything: 7 React Patterns That Make Your Code Simpler

许多 React 组件会使用 useEffect 来处理数据获取、响应属性变化、计算值以及保持状态同步。几个月后当你再次打开该文件时,会发现其中包含了四个 Effect、三个派生状态、两个加载标志,还有一个无人能解释的错误。本指南会在你编写任何钩子之前提出一个核心问题:“这真的需要使用 Effect 吗?”,并给出七个具体的解决方案,用于处理那些不需要使用 Effect 的情况。

派生值应放在 render 中,而非状态中

最常见的误用就是存储那些本可以计算得到的值。假设你要展示根据搜索查询筛选出的产品,常见的初步处理方式是添加一个用于存储结果的状态变量:

const [filteredProducts, setFilteredProducts] = useState([]);

然后再加上一个 Effect,在输入发生变化时重新计算该值:

useEffect(() => {
  setFilteredProducts(
    products.filter(product =>
      product.name.toLowerCase().includes(query.toLowerCase())
    )
  );
}, [products, query]);

这种方式可行,但filteredProducts并非独立状态。它实际上只是对你已有的值的纯函数计算结果:

products
+
query
=
filteredProducts

将其存储起来会导致React先渲染过时的结果,然后再执行Effect函数并再次渲染。应直接在组件主体中计算该值:

const filteredProducts = products.filter(product =>
  product.name.toLowerCase().includes(query.toLowerCase())
);

这样一来就不会有额外的状态变量,也不会需要Effect函数,无需同步任何内容,更不必为生成结果而进行第二次渲染。如果过滤操作确实非常耗资源,比如在处理极大的列表时,可将同一表达式用useMemo封装起来:

const filteredProducts = useMemo(() => {
  return products.filter(product =>
    product.name.toLowerCase().includes(query.toLowerCase())
  );
}, [products, query]);

使用memo只是细节问题。关键原则是:从属性或状态派生出的值无需存储在状态中。

用户操作应放在事件处理函数中

另一种模式是通过状态来传递点击事件,以便Effect函数能够对其作出响应。首先需要一个标志位:

const [shouldSave, setShouldSave] = useState(false);

然后是一个监控该标志的 Effect,以及一个用于设置它的处理函数:

useEffect(() => {
  if (shouldSave) {
    saveUser();
    setShouldSave(false);
  }
}, [shouldSave]);function handleSave() {
  setShouldSave(true);
}

没有必要绕道。点击时刻正是动作应该发生的时机,而且你已经有了在那个时刻运行的函数:

function handleSave() {
  saveUser();
}

比较这两种流程。使用标志的版本在数据被保存之前要经过多个步骤,之后还会触发另一次渲染来重置自身:

Click
  ↓
State update
  ↓
Render
  ↓
Effect
  ↓
saveUser()
  ↓
Another state update
  ↓
Render

直接版本只需一个步骤:

Click
  ↓
saveUser()

一般原则是:事件应在事件处理函数中处理,而 Effect 的存在是为了与 React 之外的系统同步,而非用来掩盖普通的事件逻辑。

只有当需要两个值时才将属性复制到状态中

这个看起来似乎无害。组件会保留自己的名称副本:

const [name, setName] = useState('');

每当属性发生变化时,Effect 会覆盖那个副本:

useEffect(() => {
  setName(user.name);
}, [user]);

现在存在两个信息来源,任何时候都需判断哪个是正确的:

user.name
     +
name

如果只是显示该值,那就直接从属性中读取即可:

return <h2>{user.name}</h2>;

有一种合理的例外情况:编辑表单会刻意保存服务器数据的临时可编辑副本,直到用户保存或取消操作:

Server data
    ↓
Edit form
    ↓
User changes fields
    ↓
Save / Cancel

在这里,本地状态是正确的,因为草稿确实是一个拥有独立生命周期的独立值。其教训并非“永远不要将属性复制到状态中”,而是“要明白为何要创建第二个数据源”。对于表单场景,通过为表单设置与记录ID关联的key来重置草稿,通常比使用Effect进行同步更为简洁;我们关于替代状态同步Effect的文章详细介绍了这一技术。

让数据层负责数据获取

数据加载功能往往分散在多个Effect中。它始于组件挂载时的第一次请求:

useEffect(() => {
  fetchUser();
}, []);

随后逐渐发展为一系列为同一用户获取数据的Effect:

useEffect(() => {
  fetchOrders();
}, [userId]);useEffect(() => {
  fetchNotifications();
}, [userId]);useEffect(() => {
  fetchPermissions();
}, [userId]);

此时该组件已转变为 API 协调层。在规模较大的应用中,专门的数据获取解决方案能发挥重要作用。根据所使用的技术栈,这些方案可能包括:

  • TanStack Query
  • SWR
  • 框架自带的数据获取功能
  • 适用场景下的 Server Components

否则就需要为每个请求手动处理所有相关问题:

loading
error
data
refetch
cache
retry
stale data
race conditions

数据层会为您处理这一切。从概念上讲,查询语句用于指定所需数据及其获取方式,并返回当前状态:

const { data, isLoading, error } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId)
});

查询键同时充当缓存键,因此请求相同用户的组件会共享同一次请求;更改 userId 后则会获取新用户的数据,而不会使用过时的响应。这样组件就能重新渲染界面,而无需充当小型网络库的功能。

使用useTransition标记非紧急更新

想象一个会驱动昂贵数据表的搜索框。每次按键都会迫使表格在输入显示下一个字符之前重新渲染:

function handleChange(e) {
  setQuery(e.target.value);
}

解决办法是将值分为两部分:一部分用于在输入框中显示,另一部分用于表格过滤:

const [inputValue, setInputValue] = useState('');
const [query, setQuery] = useState('');

输入值会正常更新,而查询相关的更新则被包裹在startTransition中:

const [isPending, startTransition] = useTransition();function handleChange(e) {
  const value = e.target.value;  setInputValue(value);  startTransition(() => {
    setQuery(value);
  });
}

现在React会对这两种更新进行不同处理。输入操作属于紧急操作:

Typing
↓
Urgent

而过滤和重新渲染操作则可以被打断或延迟:

Filtering/rendering
↓
Can wait

过渡并不会让慢速的代码变快;它只是改变调度方式,以确保界面始终响应迅速,并允许新的按键中断正在进行的渲染过程。isPending 可用于提示表格仍在处理中。

在缓存之前先进行测量

另一种错误的优化方式是盲目优化。它通常始于某次缓存的计算:

const result = useMemo(() => {
  return calculateSomething(data);
}, [data]);

接着是缓存的映射关系:

const users = useMemo(() => {
  return data.map(...);
}, [data]);

然后是缓存的列定义:

const columns = useMemo(() => [...], []);

以及缓存的选项:

const options = useMemo(() => [...], []);

很快,组件中的每个值都被包裹在缓存中,但却没人去检查是否有任何操作变得缓慢。useMemo 可以帮助处理耗时的操作,并为缓存的子组件提供稳定的引用,但优化应当是在循环之后进行:

Problem
  ↓
Measure
  ↓
Find bottleneck
  ↓
Optimize
  ↓
Measure again

而非走捷径:

Component feels complicated
  ↓
Add useMemo

对于简单的计算而言,记忆化只会增加读取开销,并且会导致依赖数组失效。React DevTools Profiler 可以帮助你判断某个组件是否真的是性能瓶颈。

使用 React 19 新增的原始函数

在早期版本的 React 中,有一些习惯性的做法现在有了更简单的替代方案。过去,若要将 ref 传递给自己的组件,就必须用 forwardRef 将其包裹起来。而在 React 19 中,函数组件可以直接将 ref 作为普通属性接收:

function CustomInput({ ref, ...props }) {
  return <input ref={ref} {...props} />;
}

乐观 UI 是另一个例子。无需手动将临时项添加到状态中,也不必记得之后再将其移除或替换:

setTodos(prev => [
  ...prev,
  {
    id: 'temp',
    text,
    pending: true
  }
]);

你可以使用 useOptimistic,它可以根据真实状态生成一个临时视图:

const [optimisticTodos, addOptimisticTodo] = useOptimistic(
  todos,
  (state, todo) => [...state, todo]
);

在异步操作进行时会显示乐观值,一旦操作完成则恢复为真实的todos列表,因此无需清理临时项。该函数应在Action或过渡过程中调用;关于请求失败的特殊情况,可参考我们关于useOptimistic回滚失败模式的分析。

为确保无障碍性,将标签与输入框关联时,请使用内置钩子:

const id = useId();

而非随机生成一个ID:

const id = `input-${Math.random()}`;

随机生成的ID会在每次渲染时变化,且服务器端与客户端之间的ID不一致,这会破坏数据同步及标签与输入框的关联。React不断添加能够直接表达设计意图的原始组件,因此应避免用过时的模式来解决当前问题。

添加钩子前的决策清单

真正掌握 React 的关键在于知道何时不应使用钩子。一个组件并不会因为使用了所有钩子就变得更好:

useEffect()
useMemo()
useCallback()
useRef()
useState()

应追求易于理解的数据流。在考虑使用钩子之前,先明确问题所在,再选择合适的工具来解决它。

对于派生数据:

Calculate it.

对于用户操作:

Handle the event.

对于与 React 之外的元素进行同步,比如订阅、计时器或非 React 组件:

Consider useEffect.

对于从外部存储读取数据:

Consider useSyncExternalStore or a state library.

对于那些重要但不紧急的更新:

Consider useTransition.

对于可能计算成本较高的操作:

Measure first, then consider useMemo.

以及当 React 已经提供了用于该任务的原生功能时:

Use the primitive instead of rebuilding it yourself.

核心要点

  • 最健康的组件往往包含最少的冗余钩子,而非最多的。
  • 将“我在哪里可以使用useEffect?”改为“这真的需要使用Effect吗?”,大多数情况下答案会指向渲染时的计算、事件处理程序或数据库。
  • 在了解新React API的语法之前,先弄清楚它解决的是什么问题。
  • 有时,组件中最有效的优化方式是删除某个钩子,而非添加新的钩子。