如何在 useEffect 中正确获取数据,避免不必要的 re-renders 或 bugs?
在 useEffect 中获取数据时,要写全依赖、处理请求失败,并在清理时取消请求或忽略过期结果。请求开始、成功或失败后,更新 state 引起重新渲染是正常行为。如果请求反复触发、旧响应覆盖新数据,或需要通过 Effect 同步多份重复 state,就需要检查相关逻辑。
哪些逻辑应该放进 useEffect?
判断逻辑放在哪里,要看它由什么触发:
| 场景 | 放在哪里 | 示例 |
|---|---|---|
| 根据已有数据计算界面内容 | 组件渲染过程 | 筛选列表、拼接姓名 |
| 用户执行某个操作 | 事件处理函数 | 点击保存后提交表单 |
| 根据当前界面状态与外部系统同步 | useEffect | 根据 userId 获取用户详情、建立订阅 |
筛选列表通常直接计算即可:
const visibleUsers = users.filter((user) =>
user.name.includes(keyword)
);
如果把 visibleUsers 再存进 state,并通过 Effect 更新,就多了一份需要同步的数据,更新流程也会变复杂。只有计算确实昂贵时,才需要考虑用 useMemo 缓存结果。React 官方说明
点击保存后提交数据,适合放在事件处理函数中:
async function handleSave() {
await saveProfile(form);
}
用户详情页需要在 userId 改变时加载对应数据,适合使用 Effect。渲染函数本身应保持纯净,不要直接在组件函数体中调用 fetch()。Effect 与事件的区别
useEffect 和组件生命周期是什么关系?
组件会经历挂载、更新和卸载。理解 Effect 时,可以关注每一次同步:何时开始,何时失效,以及失效后如何清理。
以用户详情为例:
- 组件挂载并提交后,开始请求当前用户。
userId改变后,先清理上一次 Effect,再请求新用户。- 组件卸载时,执行最后一次清理。
cleanup 在依赖变化时也会执行,所以不能只把它理解为“组件卸载回调”。Effect 的生命周期
依赖数组决定 Effect 何时需要重新执行:
| 写法 | 执行时机 |
|---|---|
useEffect(fn) | 每次组件提交后 |
useEffect(fn, []) | 挂载后执行;普通更新不会因依赖变化重跑 |
useEffect(fn, [userId]) | 挂载后执行,以及 userId 改变后 |
React 使用 Object.is 比较依赖。Effect 中读取的 props、state,以及组件内部声明的相关变量和函数,都需要纳入依赖分析。通过删除依赖来控制请求次数,容易让 Effect 读到旧值。[] 也不代表整个应用生命周期内绝对只执行一次。useEffect API
一个小型项目:切换用户并加载详情
假设已有一个 React 项目,同源后端提供以下接口:
GET /api/users/:userId
成功响应格式约定为:
{
"id": "1",
"name": "林晓",
"email": "lin@example.com"
}
这是示例接口,需要由后端或开发环境的 Mock 提供。
目录可以按业务功能组织:
src/
├── main.jsx
├── App.jsx
└── features/
└── users/
├── api/
│ └── getUser.js
├── hooks/
│ └── useUser.js
└── components/
└── UserCard.jsx
用户功能的请求函数、Hook 和组件都放在 users 目录下,修改时比较方便。等其他业务也需要通用按钮、弹窗时,再增加 shared/components/,小项目不必提前建很多空目录。
1. 请求函数只负责 HTTP 和响应解析
src/features/users/api/getUser.js:
export async function getUser(userId, signal) {
const response = await fetch(
`/api/users/${encodeURIComponent(userId)}`,
{ signal }
);
if (!response.ok) {
throw new Error(`加载用户失败,HTTP ${response.status}`);
}
return response.json();
}
fetch() 遇到 HTTP 404、500 时,通常仍会返回 Response,所以要检查 response.ok。网络失败、请求取消或 JSON 解析失败也可能抛出异常,由调用方处理。Fetch API 说明
2. 自定义 Hook 管理请求状态和清理
src/features/users/hooks/useUser.js:
import { useEffect, useState } from "react";
import { getUser } from "../api/getUser.js";
export function useUser(userId) {
const [result, setResult] = useState({
status: "loading",
user: null,
error: null,
});
useEffect(() => {
const controller = new AbortController();
let active = true;
setResult({
status: "loading",
user: null,
error: null,
});
async function loadUser() {
try {
const user = await getUser(userId, controller.signal);
if (!active) return;
setResult({
status: "success",
user,
error: null,
});
} catch (error) {
if (!active || controller.signal.aborted) return;
setResult({
status: "error",
user: null,
error:
error instanceof Error
? error.message
: "加载用户失败",
});
}
}
loadUser();
return () => {
active = false;
controller.abort();
};
}, [userId]);
return result;
}
Effect 回调没有写成 async,因为 async 函数会返回 Promise,而 React 需要这个回调返回清理函数,或者不返回内容。因此,异步函数定义在 Effect 内部,再由回调调用。
这里同时使用了两种清理措施:
controller.abort()尝试取消旧请求,减少无用工作。active标记阻止已经失效的异步逻辑更新 state,成功和失败分支都会检查它。
例如,用户先选择 A,随后选择 B。如果 A 的响应比 B 更晚返回,清理逻辑可以阻止 A 覆盖当前结果。这种响应顺序与请求顺序不一致的问题称为竞态条件。取消请求也不代表服务端一定没有收到或处理它。请求清理与竞态处理
3. 展示组件只接收数据
src/features/users/components/UserCard.jsx:
export function UserCard({ user }) {
return (
<article>
<h2>{user.name}</h2>
<p>{user.email}</p>
</article>
);
}
UserCard 不需要知道数据来自请求、缓存还是测试数据。列表页、搜索结果页需要展示相同信息时,也可以复用它。
4. 页面负责选择用户和组合组件
src/App.jsx:
import { useState } from "react";
import { useUser } from "./features/users/hooks/useUser.js";
import { UserCard } from "./features/users/components/UserCard.jsx";
function UserDetails({ userId }) {
const { status, user, error } = useUser(userId);
if (status === "loading") {
return <p role="status">正在加载用户信息...</p>;
}
if (status === "error") {
return <p role="alert">{error}</p>;
}
if (!user) {
return <p>没有找到用户。</p>;
}
return <UserCard user={user} />;
}
export default function App() {
const [userId, setUserId] = useState("1");
return (
<main>
<label>
选择用户:
<select
value={userId}
onChange={(event) => setUserId(event.target.value)}
>
<option value="1">用户 1</option>
<option value="2">用户 2</option>
</select>
</label>
<UserDetails key={userId} userId={userId} />
</main>
);
}
使用 key={userId} 后,切换用户会让详情组件重新挂载,立即回到初始加载状态,避免短暂展示上一位用户的信息。这也会重置详情组件内的所有本地 state。如果详情中还有需要保留的编辑内容,就需要另外设计状态归属,不能直接照搬这种重置方式。通过 key 重置状态
为什么有时会反复请求或显示旧数据?
请求结果被写进了请求依赖
下面的代码会形成循环:
useEffect(() => {
fetch("/api/users")
.then((response) => response.json())
.then(setUsers);
}, [users]);
请求结束后,setUsers 保存新的数组,users 随之改变,又触发下一次请求。
依赖应当是请求所需的输入,例如 userId、页码和筛选条件。只有 Effect 确实读取了某个值,并且需要随它的变化重新同步时,才把它作为依赖。
每次渲染都创建新的对象或函数依赖
const options = { userId };
useEffect(() => {
// 获取数据
}, [options]);
组件每次执行都会创建新的 options。即使 userId 没变,对象引用也变了。
只供 Effect 使用的对象可以放进 Effect,让依赖数组直接使用请求所需的输入:
useEffect(() => {
const options = { userId };
// 使用 options 获取数据,并处理清理
}, [userId]);
函数依赖也有类似问题。可以优先把逻辑移进 Effect,或把不依赖组件状态的函数放到模块作用域。确有需要时,再使用 useCallback 稳定函数引用。
为了“只请求一次”省略依赖
如果 Effect 读取了 userId,却使用 [],切换用户后就不会获取新用户的数据。异步函数还可能继续使用创建它那次渲染中的旧值,这通常称为 stale closure。
保留 React Hooks 的依赖检查规则,并根据警告调整逻辑。排查 Effect 无限循环时,可以先看它更新了什么 state,再看这次更新是否改变了某个依赖。useEffect 排查说明
开发环境中看到了两次请求
当应用根部启用 StrictMode 时,开发环境会额外执行一次 Effect 的 setup、cleanup 检查。Network 面板里可能出现一次被取消的请求和一次后续请求。
遇到这种开发检查行为,应检查清理是否正确。不要用 useRef 标记“已经执行过”来跳过后续 Effect,因为组件真实卸载后重新挂载时,也需要正常加载数据。StrictMode 说明
state 应该放在哪里?什么时候需要状态管理工具?
上例把 userId 放在 App,因为选择框和详情区域都需要它;请求状态则留在使用 useUser 的详情组件中。
其他状态可以按实际需求安排:
| 需求 | 常见做法 |
|---|---|
| 只有一个组件使用 | 本地 useState |
| 多个相邻组件需要协调 | 提升到最近的共同父组件 |
| 状态转换很多,更新规则难以分散维护 | useReducer |
| 多层组件都需要主题、当前账号等值 | 考虑 Context |
| 多处需要同一份服务端数据 | 使用支持缓存的数据获取方案 |
状态提升后,由父组件保存唯一的数据来源,再通过 props 和回调连接子组件,可以减少不同组件各存一份数据造成的不一致。共享 state 的方法
自定义 Hook 复用的是逻辑。两个组件各自调用一次 useUser("1"),通常仍然拥有独立 state,也会各自发起请求,不会自动共享缓存。自定义 Hook 的状态独立性
项目扩大后,还要补上什么?
手写 Effect 适合学习请求和清理机制。当页面需要跨组件缓存、重复请求合并、重试或预加载时,应优先评估框架已有的数据加载机制,或带客户端缓存的数据获取库。Effect 只在客户端运行,无法单独解决服务端首屏数据加载问题。React 对数据获取方案的说明
排查性能时,要区分“组件重新渲染”和“接口重新请求”。父组件更新可能让子组件重新渲染,但依赖没有变化时,Effect 可以跳过执行。因此,看到 render 时,不必马上添加 memo、useMemo 或 useCallback。
检查这个小项目时,优先验证慢网下快速切换用户能否显示正确结果,同时检查请求失败是否有提示、离开页面后旧响应是否仍会改动界面。确认这些行为后,再根据实际性能瓶颈做优化。
备注:内容仅供参考。
