选择合适的 React 工具:Derive、Handle、Fetch、Defer 还是 Effect
一份决策指南,介绍如何用派生值、事件处理程序、数据层、useTransition、measured useMemo以及React 19 API来替代传统的useEffect调用。
来源: 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的语法之前,先弄清楚它解决的是什么问题。
- 有时,组件中最有效的优化方式是删除某个钩子,而非添加新的钩子。