正确使用 useEffect:依赖、清理与异步竞态
区分渲染计算、事件处理和外部同步,通过请求示例掌握 Effect 依赖、AbortController 与过期响应处理。
useEffect 不是组件里所有逻辑的收纳箱。它更适合在提交更新后,把 React 状态与外部系统同步,例如网络连接、浏览器事件、计时器或第三方组件。
如果先问清楚“这段逻辑为什么要运行”,很多 Effect 根本不需要写。
1. 三种逻辑,三个位置
| 逻辑 | 推荐位置 | 示例 |
|---|---|---|
| 从现有数据计算结果 | 渲染过程中 | 根据商品计算总价 |
| 由用户操作触发的业务 | 事件处理函数 | 点击按钮提交订单 |
| 与外部系统保持同步 | Effect | 根据用户 ID 请求资料 |
例如,过滤列表可以直接计算:
const visibleItems = items.filter(item => item.title.includes(keyword));
不必用 Effect 计算后再存入另一份 state。那样既增加同步成本,也可能先提交旧结果,再触发一次更新。
2. 依赖数组描述同步条件
useEffect(() => {
document.title = `正在查看:${title}`;
}, [title]);
Effect 读取了响应式值 title,就应把它列入依赖。React 在提交后根据依赖变化决定是否重新执行同步。
空数组不是强制只执行一次的开关。在开发环境的 Strict Mode 中,首次挂载时可能出现额外的 setup、cleanup、setup 周期。生产环境没有这次开发检查,但组件真实卸载再挂载时仍然会重新运行。
不要通过省略依赖逃避重新执行。应先检查逻辑是否属于 Effect,或把仅在 Effect 中使用的对象和函数移到内部。
3. 异步请求需要处理竞态
假设用户先查看 A,再立刻切换到 B。A 的请求可能比 B 更晚返回。如果没有保护,旧结果就会覆盖新结果。
下面的客户端组件同时使用请求取消、过期标记和结果归属检查。示例假设 /api/users/:id 返回符合 User 的 JSON;这是接口约定,不代表当前博客提供了这个接口。在 Next.js App Router 中,应将组件放在客户端组件边界内。
import { useEffect, useState } from "react";
type User = { id: string; name: string };
type RequestState = {
userId: string;
status: "loading" | "success" | "error";
user: User | null;
error: string;
};
export function UserProfile({ userId }: { userId: string }) {
const [request, setRequest] = useState<RequestState | null>(null);
useEffect(() => {
const controller = new AbortController();
let active = true;
async function loadUser() {
setRequest({ userId, status: "loading", user: null, error: "" });
try {
const response = await fetch(
`/api/users/${encodeURIComponent(userId)}`,
{ signal: controller.signal }
);
if (!response.ok) throw new Error("用户资料加载失败");
const user: User = await response.json();
if (active) {
setRequest({ userId, status: "success", user, error: "" });
}
} catch (error) {
if (!active || controller.signal.aborted) return;
setRequest({
userId,
status: "error",
user: null,
error: error instanceof Error ? error.message : "发生未知错误",
});
}
}
void loadUser();
return () => {
active = false;
controller.abort();
};
}, [userId]);
if (!request || request.userId !== userId || request.status === "loading") {
return <p role="status">正在加载用户资料……</p>;
}
if (request.status === "error") return <p role="alert">{request.error}</p>;
return <h2>{request.user?.name}</h2>;
}
不要把 Effect 回调直接声明为 async。React 需要的是清理函数或 undefined,而 async 函数返回的是 Promise。
AbortController 尽量取消浏览器侧的请求与响应读取,但不能保证服务端已经开始的工作停止。active 防止旧任务提交状态;结果里的 userId 避免切换 ID 后、下一轮 Effect 开始前短暂展示上一个用户。
TypeScript 类型不等于运行时验证。真实接口面对不可信数据时,还应在边界检查响应结构。
4. 清理函数要对称
| setup | cleanup |
|---|---|
addEventListener |
removeEventListener |
setInterval |
clearInterval |
| 建立连接 | 关闭连接 |
| 启动可取消请求 | 取消请求并忽略过期结果 |
清理不仅发生在卸载时。依赖变化后,React 也会先清理上一轮 Effect,再执行新的同步逻辑。
5. 什么时候不该手写请求 Effect
需要缓存、请求去重、预加载或服务端渲染时,优先考虑框架的数据加载能力或成熟的请求库。手写 Effect 是理解生命周期和竞态的好练习,但不是所有页面的默认数据架构。
小结
写 Effect 前先问:是否真的需要与外部系统同步?如果需要,再明确依赖、清理方式和过期结果策略。一个正确的 Effect,应该能够安全地经历建立、清理和重新建立。