PAYATHON 2026

如何在 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 时,可以关注每一次同步:何时开始,何时失效,以及失效后如何清理。

以用户详情为例:

  1. 组件挂载并提交后,开始请求当前用户。
  2. userId 改变后,先清理上一次 Effect,再请求新用户。
  3. 组件卸载时,执行最后一次清理。

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。

检查这个小项目时,优先验证慢网下快速切换用户能否显示正确结果,同时检查请求失败是否有提示、离开页面后旧响应是否仍会改动界面。确认这些行为后,再根据实际性能瓶颈做优化。

备注:内容仅供参考。