西瓜博客
返回所有文章

正确使用 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,应该能够安全地经历建立、清理和重新建立。