React Hooks:useEffect 的边界,哪些逻辑不该写进去

一个组件里只要开始出现下面这些现象,基本就说明 useEffect 已经有点失控了:
一堆 effect 在互相传球,A 改了触发 B,B 又触发 C。
明明只是算个列表、拼个字段、响应一个点击,却非要先 setState,再等 effect 跑。
依赖数组越来越像玄学,写全了死循环,少写了出旧值。
问题的根子通常只有一个:
useEffect 本来是 React 给你的一个“逃生舱”,用来同步外部系统,结果很多人把它当成了组件内部逻辑的总调度室。
这一篇就专门来讲清楚这条边界。
一、先记住一句判断标准
React 官方对 useEffect 的定位,已经说得很清楚了:
如果这里没有“外部系统”参与,那你通常就不需要 Effect。
这里的“外部系统”包括这些东西:
网络请求。
浏览器 API,比如 title、scroll、storage。
DOM 订阅和第三方库。
定时器、WebSocket、事件监听。
反过来说,如果你做的只是这些事,那大概率不该用 effect:
根据已有 state/props 计算一个新值。
响应用户点击、输入、提交这样的事件。
在组件内部同步两份本来就可以推导的数据。
初始化一个本来就能直接传给 useState 的默认值。
换句话说,useEffect 不是“组件里任何在 render 之外运行的代码”都该去的地方。它应该是 React 和外部世界之间的一条同步通道。
二、最常见的误用:把“计算属性”写进 useEffect
先看一段很典型的代码:
function UserList({ users, keyword }: Props) {
const [filteredUsers, setFilteredUsers] = useState<User[]>([]);
useEffect(() => {
const next = users.filter(user =>
user.name.toLowerCase().includes(keyword.toLowerCase())
);
setFilteredUsers(next);
}, [users, keyword]);
return (
<ul>
{filteredUsers.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}很多人第一次看会觉得这段没毛病。输入变化了,effect 就重新过滤,再把结果存进 state。
但这其实是 React 官方最反对的一类写法之一,因为它把一个纯计算,拆成了“渲染 → effect → setState → 再渲染”的双阶段流程。
问题有三个:
filteredUsers 完全可以由 users 和 keyword 推导出来,它不是独立状态。
每次都额外多一次渲染,代码更慢,也更绕。
一旦有别的地方也能改 filteredUsers,数据就容易失真。
重构其实很简单:
function UserList({ users, keyword }: Props) {
const filteredUsers = useMemo(() => {
return users.filter(user =>
user.name.toLowerCase().includes(keyword.toLowerCase())
);
}, [users, keyword]);
return (
<ul>
{filteredUsers.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}如果这个过滤本身不重,甚至连 useMemo 都不一定需要,直接在渲染阶段计算就行。
const filteredUsers = users.filter(...);这里真正重要的不是 useMemo,而是一个判断:
能在渲染阶段直接算出来的值,就不要再绕去 effect 里“补算一次”。
三、第二种误用:用 useEffect 响应用户事件
再看一个更隐蔽、但项目里非常常见的写法:
function Form() {
const [jsonToSubmit, setJsonToSubmit] = useState<string | null>(null);
useEffect(() => {
if (!jsonToSubmit) return;
fetch('/api/register', {
method: 'POST',
body: jsonToSubmit,
});
}, [jsonToSubmit]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const json = JSON.stringify({ hello: 'world' });
setJsonToSubmit(json);
}
return <form onSubmit={handleSubmit}>...</form>;
}这类代码的思路通常是:
点击提交按钮。
先把要提交的数据塞进 state。
再让 effect 监听这个 state,顺便发请求。
看起来像是在“解耦”,其实是在绕路。
React 官方明确建议: 如果某段逻辑是因为“用户做了某个动作”才发生,那它应该待在事件处理函数里,而不是放进 effect。
更直接的写法是:
function Form() {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const json = JSON.stringify({ hello: 'world' });
await fetch('/api/register', {
method: 'POST',
body: json,
});
}
return <form onSubmit={handleSubmit}>...</form>;
}为什么这样更好?
因果关系清楚:提交按钮触发请求。
不需要额外制造一个中间状态。
不会出现“某个别的地方改了 jsonToSubmit,结果又触发提交”的意外副作用。
一句话总结:
“因为组件显示出来了”而发生的逻辑,适合放 effect;“因为用户做了某个动作”而发生的逻辑,优先放事件处理器。
这个界线一旦立住,很多 effect 会自动消失。
四、第三种误用:用 useEffect 同步组件内部状态
再看一个经典例子:
function Cart({ items }: { items: Item[] }) {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(items.length);
}, [items]);
return <div>共 {count} 件商品</div>;
}这个问题和前面的过滤一样:count 本质上是 items.length 的别名,不是一个独立事实。
更合理的写法:
function Cart({ items }: { items: Item[] }) {
const count = items.length;
return <div>共 {count} 件商品</div>;
}这种“内部状态同步”最大的问题在于,它很容易长成链式更新:
items 变了,effect 去改 count。
count 变了,另一个 effect 再去改 totalPrice。
totalPrice 变了,又有一个 effect 去做别的事。
于是一个原本可以在一行表达式里完成的数据关系,被拆成了状态机。
当你看到这种模式时,应该立刻警觉:
这不是状态管理,这是把数据计算改写成了副作用。
五、那到底什么该放进 useEffect?
说完“不该做什么”,再来讲“该做什么”。 只要记住一句话就够了:
useEffect 用来让 React 状态和外部系统保持同步。
比较标准的场景有这些:
1. 网络请求
useEffect(() => {
let ignore = false;
async function load() {
const result = await fetchUser(userId).then(r => r.json());
if (!ignore) setUser(result);
}
load();
return () => {
ignore = true;
};
}, [userId]);这里用 effect 是合理的,因为请求就是一个外部副作用,而且 React 官方也专门提醒:如果你在 effect 里请求数据,要处理清理逻辑,避免竞态条件和过期结果覆盖新结果。
2. 订阅与解绑
useEffect(() => {
const unsubscribe = chat.subscribe(roomId, onMessage);
return unsubscribe;
}, [roomId]);这类代码非常适合 effect,因为“建立连接”和“销毁连接”本来就是一对配套动作。
3. 浏览器或第三方 API
useEffect(() => {
document.title = `共 ${count} 条消息`;
}, [count]);或者:
useEffect(() => {
const observer = new IntersectionObserver(handleIntersect);
observer.observe(ref.current!);
return () => observer.disconnect();
}, []);这时候你是在同步 DOM / 浏览器能力,而不是在组件内部算数据,所以 effect 是合理的。
六、什么时候该用 useLayoutEffect,而不是 useEffect?
绝大多数场景下,默认用 useEffect 就行。
useLayoutEffect 只适合一种相对特殊的情况:
你要在浏览器绘制之前,读取布局信息(例如宽度,高度)或立刻改 DOM。
如果等到
useEffect再执行,用户可能会看到闪动或错位。
比如:
useLayoutEffect(() => {
const rect = ref.current?.getBoundingClientRect();
setTooltipPosition(calcPosition(rect));
}, []);Kent C. Dodds 的建议非常直接:99% 的时间用 useEffect,只有涉及布局测量或必须同步 DOM 外观变化时,才考虑 useLayoutEffect。
所以团队规范里完全可以写成一句:
默认
useEffect。只有“测量布局 / 绘制前 DOM 修正”才上
useLayoutEffect。
七、一个完整的重构示例:从一堆 effect 到数据流清晰
看一段更接近真实项目的坏代码:
function ProductTable({ products, category }: Props) {
const [filtered, setFiltered] = useState<Product[]>([]);
const [sorted, setSorted] = useState<Product[]>([]);
const [count, setCount] = useState(0);
useEffect(() => {
setFiltered(
products.filter(p => p.category === category)
);
}, [products, category]);
useEffect(() => {
setSorted(
[...filtered].sort((a, b) => a.price - b.price)
);
}, [filtered]);
useEffect(() => {
setCount(sorted.length);
}, [sorted]);
return (
<>
<div>数量:{count}</div>
<ul>
{sorted.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</>
);
}这段代码最可怕的地方不是“错”,而是“看起来很有条理”。 每个 effect 都只做一件事,但整体上完全是副作用驱动的数据流。
重构后:
function ProductTable({ products, category }: Props) {
const filtered = useMemo(() => {
return products.filter(p => p.category === category);
}, [products, category]);
const sorted = useMemo(() => {
return [...filtered].sort((a, b) => a.price - b.price);
}, [filtered]);
const count = sorted.length;
return (
<>
<div>数量:{count}</div>
<ul>
{sorted.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</>
);
}这样改完之后有几个明显好处:
数据关系是显式的:
products -> filtered -> sorted -> count。没有多余渲染链。
不存在 effect 依赖数组写错导致局部失效的问题。
组件读起来像“数据变换”,而不是“副作用流程图”。
你会发现,很多所谓的 Hooks 最佳实践,最后都落到同一个方向上:让组件更像纯函数,让 effect 只处理那些纯函数处理不了的东西。
八、可以直接落地的 useEffect 判断清单
写代码时,准备新加一个 useEffect,先问自己这几个问题:
这里有没有外部系统?
没有,就先怀疑这个 effect 是否真的需要。
这段逻辑是不是因为“组件显示出来了”而发生?
如果是因为“用户点击了按钮”,优先放事件里。
这个值是不是能从现有 props/state 算出来?
能算出来,就不要再建 state,更不要 effect + setState。
这个 effect 里是否真的需要清理?
订阅、监听、定时器、请求竞态都需要 cleanup。
这段逻辑如果删掉 effect,改成 render 里的表达式或事件处理器,会不会更直接?
大多数时候,答案是:会。
在 Google 上继续关注
把 HeyBinyang 添加为 Google 首选来源
如果你愿意继续在 Google 里读到我的更新,可以把这个站点添加为 preferred source,之后更容易在相关内容场景里看到它。
SHARE
分享
分享这篇文章。