React 基础体系 · 第 6/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React Effect 完整指南:同步外部系统、依赖、清理和竞态

useEffect 经常被概括为“组件副作用”,但这个说法不够精确。Effect 的核心职责是:

在 React 提交组件输出之后,把 React 管理的状态同步到 React 不负责管理的外部系统;当依赖变化或组件卸载时,再撤销上一次同步。

“外部系统”包括:

  • 浏览器 DOM API,例如 document.title、事件监听器、IntersectionObserver
  • 定时器、WebSocket、WebRTC、地图 SDK、第三方图表库;
  • 订阅系统,例如事件总线、Redux store、媒体查询;
  • 网络请求,以及请求对应的加载、成功和失败状态。

Effect 不是“所有异步代码的容器”,也不是“把生命周期方法搬到函数组件”的通用替代品。理解它需要同时掌握组件渲染、状态快照、提交阶段、依赖比较、清理函数和异步竞态。


1. 先区分:渲染、提交和 Effect

一个 React 更新大致经过三个阶段:

  1. 触发更新:调用 setState、父组件重新渲染,或外部订阅触发更新;
  2. 渲染(render):React 调用组件函数,计算新的 JSX;
  3. 提交(commit):React 把差异应用到 DOM;
  4. 运行 Effect:浏览器绘制后,React 运行 useEffect 的 setup 函数。
import { useEffect, useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  console.log('render', count);

  useEffect(() => {
    console.log('effect', count);
  });

  return (
    <button onClick={() => setCount((value) => value + 1)}>
      {count}
    </button>
  );
}

点击按钮时,典型顺序是:

调用 setCount
  ↓
组件函数重新执行:render 1
  ↓
React 提交新的按钮文本
  ↓
浏览器绘制
  ↓
Effect 执行:effect 1

Effect 运行时,DOM 通常已经反映了本次渲染的结果。因此,Effect 适合连接 DOM 之外的系统,或者在 DOM 更新后读取、操作外部环境。

1.1 Effect 中读取到的是某次渲染的快照

每次渲染都会创建一个新的闭包。下面的 count 不是一个会自动变化的变量,而是当前渲染的状态快照:

function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = window.setTimeout(() => {
      console.log(count);
    }, 1000);

    return () => window.clearTimeout(id);
  }, [count]);

  return (
    <button onClick={() => setCount((value) => value + 1)}>
      {count}
    </button>
  );
}

count0 时创建的定时器,闭包里保存的就是 0。之后即使组件状态变为 1,这个定时器也不会自动读取新值。依赖 [count] 的作用是:

  1. count 变化;
  2. React 先清理旧定时器;
  3. 再创建捕获新 count 的定时器。

这与“状态快照”直接相关:状态更新不会修改已经执行过的那次组件函数中的局部变量。


2. useEffect 的精确定义:setup 与 cleanup

useEffect 的形式是:

useEffect(setup, dependencies?)

setup 可以返回一个清理函数:

useEffect(() => {
  // setup:建立同步关系

  return () => {
    // cleanup:撤销同步关系
  };
}, dependencies);

可以把它抽象为一组状态转换:

旧依赖 D_old
  ↓
运行 cleanup(D_old)
  ↓
新依赖 D_new
  ↓
运行 setup(D_new)

当组件卸载时,React 会运行最后一次 setup 对应的 cleanup。

2.1 三种依赖写法

没有第二个参数:每次提交后运行

useEffect(() => {
  console.log('每次组件提交后运行');
});

这包括初次提交和后续每次重新渲染。它适合非常少见的“每次提交都要同步”的场景,否则通常会产生多余工作。

空数组:只针对一次挂载过程建立同步

useEffect(() => {
  console.log('建立一次连接');

  return () => {
    console.log('断开连接');
  };
}, []);

在生产环境中,通常是挂载时执行一次 setup,卸载时执行一次 cleanup。但在开发环境的 <StrictMode> 下,React 会额外执行一次:

setup → cleanup → setup

这是故意的开发检查,用来发现 setup 不可重复、cleanup 不完整的问题。它不是生产环境中真实的重复业务连接,也不能通过删除 Strict Mode 来掩盖资源泄漏。

指定依赖:依赖变化时重新同步

useEffect(() => {
  const connection = connectToRoom(roomId);

  return () => {
    connection.disconnect();
  };
}, [roomId]);

初次提交时建立 roomId 对应的连接;以后只有 roomId 变化时,才会:

断开旧 roomId 的连接
  ↓
建立新 roomId 的连接

React 会使用 Object.is 逐项比较前后依赖。因此,依赖不是深比较:

Object.is(1, 1); // true
Object.is({}, {}); // false

3. Effect 的正确模型:同步外部系统,而不是响应式生命周期回调

一个好的 Effect 可以回答两个问题:

  1. 它正在连接或更新哪个外部系统?
  2. cleanup 是否能完全撤销这次连接或更新?

例如,订阅聊天室:

import { useEffect } from 'react';

type ChatConnection = {
  connect(): void;
  disconnect(): void;
};

declare function createChatConnection(
  serverUrl: string,
  roomId: string,
): ChatConnection;

type ChatRoomProps = {
  serverUrl: string;
  roomId: string;
};

export function ChatRoom({ serverUrl, roomId }: ChatRoomProps) {
  useEffect(() => {
    const connection = createChatConnection(serverUrl, roomId);

    connection.connect();

    return () => {
      connection.disconnect();
    };
  }, [serverUrl, roomId]);

  return <p>正在连接到房间:{roomId}</p>;
}

这个 Effect 的同步关系是:

React props: serverUrl, roomId
          ↓
外部系统:对应服务器上的对应聊天室连接

roomId 改变时,不能只建立新连接而不关闭旧连接,否则会出现:

  • 同时接收两个房间的消息;
  • 重复处理事件;
  • WebSocket 连接泄漏;
  • 组件卸载后仍然更新外部系统。

3.1 cleanup 应满足“撤销 setup”

如果 setup 做了这些事:

window.addEventListener('resize', handleResize);
const timerId = window.setInterval(refresh, 5000);
const subscription = store.subscribe(handleChange);

cleanup 就应该分别撤销:

window.removeEventListener('resize', handleResize);
window.clearInterval(timerId);
subscription.unsubscribe();

下面这个写法是错误的:

useEffect(() => {
  window.addEventListener('resize', () => {
    console.log(window.innerWidth);
  });

  return () => {
    window.removeEventListener('resize', () => {
      console.log(window.innerWidth);
    });
  };
}, []);

两个箭头函数虽然代码相同,但不是同一个函数对象。浏览器无法用第二个函数移除第一个监听器。

正确做法是保存同一个函数引用:

useEffect(() => {
  const handleResize = () => {
    console.log(window.innerWidth);
  };

  window.addEventListener('resize', handleResize);

  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

4. Effect 不是事件处理器,也不是派生状态工具

4.1 用户动作应优先放在事件处理器中

下面的代码不必要地绕了一圈:

function Form() {
  const [submitted, setSubmitted] = useState(false);

  useEffect(() => {
    if (submitted) {
      sendAnalyticsEvent('form_submitted');
    }
  }, [submitted]);

  return (
    <button onClick={() => setSubmitted(true)}>
      提交
    </button>
  );
}

提交事件本身就是发送分析事件的原因,直接写在事件处理器中更准确:

function Form() {
  function handleSubmit() {
    sendAnalyticsEvent('form_submitted');
  }

  return <button onClick={handleSubmit}>提交</button>;
}

判断标准是:

  • 由某次用户动作直接触发:事件处理器;
  • 由渲染结果需要同步到外部系统:Effect;
  • 仅由已有数据计算得到:渲染期间直接计算或使用 useMemo,不需要 Effect。

4.2 不要用 Effect 计算派生状态

错误示例:

function UserName({ firstName, lastName }: Props) {
  const [fullName, setFullName] = useState('');

  useEffect(() => {
    setFullName(`${firstName} ${lastName}`);
  }, [firstName, lastName]);

  return <p>{fullName}</p>;
}

这会产生一次多余的过程:

props 改变
  ↓
第一次渲染仍使用旧 fullName
  ↓
Effect 执行 setFullName
  ↓
第二次渲染得到新 fullName

直接计算即可:

function UserName({ firstName, lastName }: Props) {
  const fullName = `${firstName} ${lastName}`;

  return <p>{fullName}</p>;
}

如果计算确实昂贵,可以考虑:

const fullName = useMemo(
  () => expensiveFormatName(firstName, lastName),
  [firstName, lastName],
);

useMemo 是性能优化工具,不是让派生数据变得“更正确”的必要机制。


5. 依赖数组:它描述的是闭包需要的响应式输入

依赖数组不是“希望 Effect 什么时候运行”的开关,而是一个声明:

这个 Effect 的 setup 使用了哪些可能随渲染变化的值?

例如:

function Room({ serverUrl, roomId }: Props) {
  const options = {
    serverUrl,
    roomId,
  };

  useEffect(() => {
    const connection = connect(options);
    return () => connection.disconnect();
  }, [options]);

  return null;
}

options 在每次渲染时都是新对象,即使 serverUrlroomId 没变:

Object.is(optionsFromRender1, optionsFromRender2); // false

因此 Effect 会在每次渲染后重启。更直接的写法是把对象创建放入 Effect:

function Room({ serverUrl, roomId }: Props) {
  useEffect(() => {
    const options = { serverUrl, roomId };
    const connection = connect(options);

    return () => connection.disconnect();
  }, [serverUrl, roomId]);

  return null;
}

此时真正决定同步关系的是两个原始值。

5.1 函数依赖也会导致重新同步

function Logger({ value }: { value: string }) {
  const format = () => value.toUpperCase();

  useEffect(() => {
    console.log(format());
  }, [format]);

  return null;
}

每次渲染都会创建新的 format,因此它被视为变化的依赖。可以将函数逻辑移入 Effect:

function Logger({ value }: { value: string }) {
  useEffect(() => {
    const format = () => value.toUpperCase();
    console.log(format());
  }, [value]);

  return null;
}

也可以使用 useCallback,但只有在确实需要稳定函数身份时才值得这样做:

const format = useCallback(() => value.toUpperCase(), [value]);

useCallback 不会让函数“忽略”它读取的值;它只是根据依赖决定是否复用函数对象。

5.2 不要随意关闭 exhaustive-deps 检查

下面的代码会制造陈旧闭包:

useEffect(() => {
  const id = window.setInterval(() => {
    console.log(count);
  }, 1000);

  return () => window.clearInterval(id);
}, []); // 缺少 count

它只会打印初次渲染捕获的 count。React 官方 ESLint 规则中的 exhaustive-deps 通常能发现这类问题。

如果 Effect 不应该因某个值变化而重启,应先重新设计同步边界,而不是简单删掉依赖。常见方案包括:

  • 把不需要响应式变化的逻辑移出组件;
  • 把对象或函数创建移入 Effect;
  • 使用 useRef 保存最新值;
  • 在当前 React 19 小版本和框架支持的情况下,考虑 useEffectEvent 表达“读取最新值但不作为 Effect 重启条件”的逻辑。

6. 读取最新值,但不因它重启 Effect

有时连接只由 roomId 决定,但收到消息时要读取当前主题:

function ChatRoom({
  roomId,
  theme,
}: {
  roomId: string;
  theme: 'light' | 'dark';
}) {
  useEffect(() => {
    const connection = connectToRoom(roomId);

    connection.on('connected', () => {
      showNotification('已连接', theme);
    });

    connection.connect();

    return () => connection.disconnect();
  }, [roomId, theme]);

  return null;
}

这里 theme 被闭包读取,所以加入依赖是规范且安全的;代价是主题变化会重连。若业务要求主题变化只影响通知样式、不应重连,就需要把这两个同步关系分离。

在支持 useEffectEvent 的 React 19 版本中,可以表达这种意图:

import { useEffect, useEffectEvent } from 'react';

function ChatRoom({
  roomId,
  theme,
}: {
  roomId: string;
  theme: 'light' | 'dark';
}) {
  const onConnected = useEffectEvent(() => {
    showNotification('已连接', theme);
  });

  useEffect(() => {
    const connection = connectToRoom(roomId);

    connection.on('connected', onConnected);
    connection.connect();

    return () => connection.disconnect();
  }, [roomId]);

  return null;
}

这里:

  • roomId 决定连接何时建立和销毁;
  • onConnected 执行时能读取最新 theme
  • theme 不再直接作为连接 Effect 的重启依赖。

该 API 的可用性、Lint 支持和框架版本需要与项目实际 React 19 小版本核对。若项目不使用它,可以用 useRef 保存最新值,但要明确那是手动维护的可变引用:

function ChatRoom({ roomId, theme }: Props) {
  const themeRef = useRef(theme);
  themeRef.current = theme;

  useEffect(() => {
    const connection = connectToRoom(roomId);

    connection.on('connected', () => {
      showNotification('已连接', themeRef.current);
    });

    connection.connect();

    return () => connection.disconnect();
  }, [roomId]);

  return null;
}

ref 的变化不会触发渲染,也不会自动通知 React;因此它适合保存“事件发生时要读取的最新值”,不适合替代需要驱动 UI 的 state。


7. Effect 的清理时序和 Strict Mode

假设依赖从 A 变成 B,React 的逻辑顺序是:

render(B)
  ↓
commit(B)
  ↓
cleanup(A)
  ↓
setup(B)

对于 useEffect,具体与浏览器绘制的相对时机受 React 调度影响;不能把它当作一个同步、立即执行的生命周期回调。通常它会在浏览器绘制后运行。若必须在浏览器绘制前同步测量布局,可以使用 useLayoutEffect,但它会阻塞绘制,应谨慎使用。

Strict Mode 开发检查可以形式化为:

mount:
  setup()
  cleanup()
  setup()

unmount:
  cleanup()

因此 setup 必须允许重复建立,cleanup 必须足够完整。例如:

useEffect(() => {
  const controller = new AbortController();

  startSomething(controller.signal);

  return () => {
    controller.abort();
  };
}, []);

如果 startSomething 无法承受 setup → cleanup → setup,问题通常在资源生命周期设计,而不是 Strict Mode 本身。


8. 网络请求:请求本身、结果提交和竞态

网络请求是 Effect 中最容易出错的场景。原因在于:

  • 请求开始顺序不代表响应结束顺序;
  • 组件可能在请求结束前卸载;
  • 依赖变化会启动多个请求;
  • 旧请求可能晚于新请求返回。

考虑搜索框:

useEffect(() => {
  fetch(`/api/search?q=${encodeURIComponent(query)}`)
    .then((response) => response.json())
    .then((data) => {
      setResults(data);
    });
}, [query]);

用户依次输入 aababc 时,可能出现:

t0: 请求 A 开始
t1: 请求 AB 开始
t2: 请求 ABC 开始
t3: 请求 ABC 返回,显示 ABC
t4: 请求 A 返回,覆盖为 A

最终 UI 显示了旧查询的结果。这就是竞态条件:多个并发操作的完成顺序影响了最终状态。

8.1 最小的“忽略旧结果”方案

import { useEffect, useState } from 'react';

type SearchResult = {
  id: string;
  title: string;
};

export function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<SearchResult[]>([]);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    let ignore = false;

    setError(null);

    fetch(`/api/search?q=${encodeURIComponent(query)}`)
      .then(async (response) => {
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        return (await response.json()) as SearchResult[];
      })
      .then((data) => {
        if (!ignore) {
          setResults(data);
        }
      })
      .catch((reason: unknown) => {
        if (!ignore) {
          setError(
            reason instanceof Error
              ? reason
              : new Error('搜索失败'),
          );
        }
      });

    return () => {
      ignore = true;
    };
  }, [query]);

  if (error) {
    return <p role="alert">{error.message}</p>;
  }

  return (
    <ul>
      {results.map((item) => (
        <li key={item.id}>{item.title}</li>
      ))}
    </ul>
  );
}

这里的 ignore 是每次 Effect 执行独立创建的局部变量:

请求 A 的 ignore_A = false
请求 AB 的 ignore_AB = false

query 从 A 变成 AB
  ↓
cleanup(A): ignore_A = true
setup(AB): ignore_AB = false

如果请求 A 后返回,它只能通过 ignore_A 判断,因此不会提交结果。请求 AB 仍能正常提交。

这解决的是结果提交竞态,但没有停止网络传输。

8.2 使用 AbortController 取消请求

浏览器的 fetch 支持 AbortSignal

import { useEffect, useState } from 'react';

type User = {
  id: string;
  name: string;
};

export function UserPanel({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [status, setStatus] = useState<
    'idle' | 'loading' | 'success' | 'error'
  >('idle');
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    setStatus('loading');
    setError(null);

    async function load() {
      try {
        const response = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }

        const data = (await response.json()) as User;

        if (!controller.signal.aborted) {
          setUser(data);
          setStatus('success');
        }
      } catch (reason: unknown) {
        if (controller.signal.aborted) {
          return;
        }

        setError(
          reason instanceof Error
            ? reason
            : new Error('加载用户失败'),
        );
        setStatus('error');
      }
    }

    void load();

    return () => {
      controller.abort();
    };
  }, [userId]);

  if (status === 'loading') {
    return <p>加载中……</p>;
  }

  if (status === 'error') {
    return <p role="alert">{error?.message}</p>;
  }

  return <p>{user?.name ?? '暂无用户'}</p>;
}

每次 userId 变化时:

  1. cleanup 调用 abort()
  2. 与旧 signal 关联的 fetch 通常会拒绝并停止后续处理;
  3. 新 Effect 创建新的 AbortController
  4. 新请求开始;
  5. 只有未取消的请求可以提交结果。

需要注意两个边界:

  • AbortController 只会通知支持该 signal 的 API;它不能自动取消任意 Promise;
  • 如果响应已经完成,代码进入了自定义的异步处理阶段,仍然需要检查请求是否已失效,或使用请求序号/忽略标志。

8.3 只取消不够:错误状态也要区分

取消通常是正常的生命周期行为,不应显示为业务错误:

catch (error) {
  if (error instanceof DOMException && error.name === 'AbortError') {
    return;
  }

  setError(error);
}

但不同运行环境、fetch 实现和封装库的取消错误类型可能不同。更稳妥的做法是以 signal.aborted 或库自身提供的取消判断为准。

8.4 请求状态机

一个请求 Effect 可以看作状态机:

idle
  ↓ 开始请求
loading
  ├── 成功且仍有效 → success
  ├── 失败且仍有效 → error
  └── 依赖变化/卸载 → aborted 或失效,不提交结果

如果旧请求在新请求之后返回,它的状态迁移必须被拒绝:

loading(query=A)
  ↓ A 返回,但 A 已失效
不允许写入 success(A)

这比单纯记住“要加 AbortController”更重要:取消是实现手段,核心不变量是:

只有当前仍然有效的请求,才能更新当前 UI 状态。


9. 请求缓存、并发和框架数据能力

直接在 Effect 中请求数据可以工作,但它没有自动提供:

  • 请求缓存;
  • 相同请求去重;
  • 预加载;
  • 服务端渲染时的数据准备;
  • 路由级错误边界;
  • Suspense 集成;
  • 离线和重新验证策略。

现代 React 应用通常还会使用框架的数据加载能力,或专门的数据管理库。框架可能在服务端、路由切换阶段或缓存层执行请求,从而减少“组件挂载后才开始请求”的瀑布。

这不意味着 Effect 请求永远错误。它适合:

  • 客户端专属数据;
  • 简单页面;
  • 没有路由级 loader 的局部交互;
  • 与浏览器环境绑定的请求触发逻辑。

但如果请求属于页面路由的主要数据,应先确认框架是否提供 loader、预取、缓存或 Suspense 方案。Effect 只负责“客户端提交后的同步”,不能单独解决整个数据获取架构。

9.1 Suspense 与 Effect 的边界

Effect 中的异步请求通常通过本地 state 表达:

loading → success/error

Suspense 则要求数据读取层在渲染期间以 React 可识别的方式“暂停”,由边界渲染 fallback。二者不是同一个机制:

  • Effect 请求:组件先提交,再开始请求;
  • Suspense 数据读取:渲染过程由资源状态决定;
  • 框架 loader:可能在渲染前或路由转换阶段加载数据。

不要因为使用了 <Suspense> 就认为 Effect 中的 fetch 会自动被 Suspense 管理;普通 Effect 请求不会自动触发 Suspense。


10. 服务端与客户端边界

useEffect 依赖浏览器提交阶段,因此不会在服务端渲染过程中运行。服务端只能生成 HTML,不能执行浏览器中的:

window.addEventListener(...)
document.title = ...
new WebSocket(...)

在支持 Server Components 的框架中,包含 useEffect 的组件必须位于客户端组件边界内。例如某些框架要求文件顶部声明:

'use client';

import { useEffect } from 'react';

这条指令属于框架约定,不是 React 核心 API 本身。具体边界规则取决于框架。

10.1 浏览器 API 必须放在客户端执行路径

错误示例:

const width = window.innerWidth;

如果这个模块在服务端被导入,就可能直接抛出:

ReferenceError: window is not defined

客户端 Effect 中读取则不会在服务端执行:

useEffect(() => {
  const handleResize = () => {
    setWidth(window.innerWidth);
  };

  handleResize();
  window.addEventListener('resize', handleResize);

  return () => window.removeEventListener('resize', handleResize);
}, []);

初次服务端 HTML 与客户端第一次渲染还必须保持可匹配,否则可能产生 hydration 不一致。不能把“首屏一定显示浏览器真实宽度”建立在服务端无法获取的信息上;可以先渲染稳定的占位值,提交后再同步浏览器状态。


11. 外部订阅与 useSyncExternalStore

对于外部 store,手写 Effect 订阅可能遗漏并发渲染下的一致性问题:

function Component() {
  const [value, setValue] = useState(store.get());

  useEffect(() => {
    return store.subscribe(() => {
      setValue(store.get());
    });
  }, []);

  return <p>{value}</p>;
}

这种写法能表达基本订阅逻辑,但 React 为外部 store 提供了专门 API:

import { useSyncExternalStore } from 'react';

function useStoreValue() {
  return useSyncExternalStore(
    store.subscribe,
    store.getSnapshot,
    store.getServerSnapshot,
  );
}

其中:

  • subscribe:注册和注销订阅;
  • getSnapshot:读取当前客户端快照;
  • getServerSnapshot:服务端渲染时读取快照,可选但在 SSR 场景很重要。

如果数据源本质上是“外部可变 store”,应优先使用 useSyncExternalStore,而不是把订阅事件转存进本地 state。Effect 更适合连接一般外部系统;useSyncExternalStore 更适合让 React 安全读取外部状态快照。


12. Effect 与状态更新、批处理的关系

Effect 中的状态更新会触发新的渲染:

useEffect(() => {
  setCount(count + 1);
}, [count]);

这会形成:

渲染 count=0
  ↓
Effect 设置 count=1
  ↓
渲染 count=1
  ↓
Effect 设置 count=2
  ↓
无限循环

依赖中的值必须在 Effect 中被读取,并且 Effect 中的更新不能无条件地再次改变同一同步关系。

如果更新依赖之前的状态,应使用函数式更新:

useEffect(() => {
  const id = window.setInterval(() => {
    setCount((value) => value + 1);
  }, 1000);

  return () => window.clearInterval(id);
}, []);

这里不需要把 count 放入依赖,因为回调没有读取闭包中的 count,而是把更新交给 React:

当前 state → React 提供 value → value + 1

React 18 及以后,现代 React 通常会对同一任务中的多个 state 更新进行批处理;这不会改变 Effect 的依赖规则,也不会让同一闭包中的状态变量自动变成最新值。批处理减少渲染次数,状态快照仍然存在。


13. useEffectuseLayoutEffectuseInsertionEffect

三者用途不同:

useEffect

用于一般外部同步:

useEffect(() => {
  document.title = title;
}, [title]);

通常不会阻塞浏览器绘制。

useLayoutEffect

用于必须在浏览器绘制前完成的布局测量或同步修正:

useLayoutEffect(() => {
  const rect = ref.current?.getBoundingClientRect();
  if (rect) {
    setHeight(rect.height);
  }
}, []);

它可能导致服务端环境警告,因为服务端没有布局。除非确实会出现视觉闪烁或测量时序问题,否则优先使用 useEffect

useInsertionEffect

主要为 CSS-in-JS 库等底层工具准备,用于在布局读取前插入样式。普通业务组件通常不应使用它。

不要把 useLayoutEffect 当作“更可靠的 useEffect”;它的代价是阻塞绘制。


14. 常见失败表现与诊断路径

14.1 Effect 每次渲染都执行

可能原因:

const options = { roomId };

useEffect(() => {
  connect(options);
}, [options]);

诊断方法:

  1. 在 Effect 和 cleanup 中打印依赖值;
  2. 使用 Object.is(previous, current) 检查身份;
  3. 检查是否每次都创建了对象、数组或函数;
  4. 将不必要的对象创建移入 Effect;
  5. 仅在确有需要时使用 useMemouseCallback

14.2 开发环境连接两次

如果日志是:

connect
disconnect
connect

首先检查是否启用了 Strict Mode。若只在开发环境出现,这通常是 React 的重复 setup 检查。真正需要修复的是:

  • setup 是否重复注册监听器;
  • cleanup 是否真的注销;
  • 是否保存了正确的资源引用;
  • 第一次连接是否能在 cleanup 中关闭。

14.3 旧请求覆盖新请求

表现为搜索词已经是 abc,结果却来自 a。应记录请求身份:

const requestId = useRef(0);

useEffect(() => {
  const id = ++requestId.current;

  void fetchData(query).then((data) => {
    if (id === requestId.current) {
      setResults(data);
    }
  });
}, [query]);

请求序号方案能保证只有最后一次请求写入,但不会取消网络。生产实现通常组合:

  • AbortController 减少无用工作;
  • 请求序号或失效标志保证结果提交正确。

14.4 卸载后仍然触发副作用

检查 Effect 是否创建了:

  • 未清理的 timer;
  • 未移除的 DOM 监听器;
  • 未关闭的 WebSocket;
  • 未取消的 observer;
  • 未停止的第三方 SDK 实例;
  • 未处理的异步结果。

在 React 现代版本中,“卸载后 setState”不应被简单理解为总会出现一个 React 警告;即使没有警告,未清理的资源仍可能泄漏或继续执行。


15. 一个完整的订阅示例:媒体查询

下面的 Hook 展示了建立订阅、读取初始值、处理变化和清理:

import { useEffect, useState } from 'react';

export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(false);

  useEffect(() => {
    const mediaQuery = window.matchMedia(query);

    const update = () => {
      setMatches(mediaQuery.matches);
    };

    update();
    mediaQuery.addEventListener('change', update);

    return () => {
      mediaQuery.removeEventListener('change', update);
    };
  }, [query]);

  return matches;
}

执行过程如下:

  1. query 变化后,创建新的 MediaQueryList
  2. update() 立即把当前浏览器状态同步到 React state;
  3. 浏览器条件变化时,事件处理器触发 setMatches
  4. query 再变化或组件卸载时,移除旧监听器。

如果项目需要把媒体查询作为共享外部状态,并且要兼容服务端快照、并发渲染和多个组件订阅,可以进一步使用 useSyncExternalStore 封装。


16. 一个 Effect 的审查方法

审查 Effect 时,可以按以下因果顺序检查,而不是先看依赖数组:

第一步:确认外部系统

问:

如果删除这个 Effect,哪个 React 之外的系统会失去同步?

如果答案是“没有”,它可能是在计算派生状态或响应用户事件,应该改用渲染计算或事件处理器。

第二步:列出 setup 的资源

例如:

创建 WebSocket
注册 resize 监听
创建 interval
启动 fetch

第三步:为每个资源找到反向操作

WebSocket.close()
removeEventListener()
clearInterval()
AbortController.abort()

如果找不到反向操作,通常存在泄漏风险。

第四步:列出闭包读取的响应式值

包括:

  • props;
  • state;
  • 组件函数中声明的变量;
  • 普通函数、对象和数组。

再判断哪些值真正决定外部系统的生命周期,哪些只是事件发生时需要读取的最新值。

第五步:验证竞态

如果 setup 启动异步工作,列出所有可能顺序:

旧请求先返回
旧请求后返回
组件先卸载
依赖先变化
请求被取消
请求失败

然后确认每条路径都不会把无效结果提交到当前 UI。


17. 规范保证、实现细节与工程取舍

需要区分三类结论:

React 规范层面的核心保证

  • Effect 的 setup 在客户端提交后运行;
  • 依赖变化时,旧 cleanup 会先于新 setup;
  • 卸载时会执行最后的 cleanup;
  • 依赖按 Object.is 比较;
  • Effect 中捕获的是对应渲染的值;
  • Effect 不在服务端渲染阶段运行。

常见但不应过度依赖的实现细节

  • Effect 通常在浏览器绘制后运行;
  • React 可能延后或合并工作;
  • 开发 Strict Mode 会额外执行 setup → cleanup → setup;
  • 某些请求取消行为取决于 fetch 或第三方库的实现。

不要依赖具体日志顺序来构建业务正确性;应依赖 cleanup、依赖声明和有效性检查表达不变量。

工程取舍

  • 简单客户端请求可以用 Effect;
  • 页面主数据通常更适合框架 loader、缓存库或 Suspense 兼容的数据层;
  • 外部 store 优先考虑 useSyncExternalStore
  • 需要布局测量才使用 useLayoutEffect
  • 需要稳定事件读取时,根据 React 19 小版本支持情况选择 useEffectEventuseRef 或拆分 Effect。

结语:Effect 的核心不变量

可以用一句形式化的话总结 Effect:

对于当前依赖快照 D,React 至多维护一个有效的外部同步 S(D);当依赖从 D₁ 变为 D₂ 时,必须先撤销 S(D₁),再建立 S(D₂);任何异步结果只有在仍属于当前有效同步时,才能更新 UI。

因此,写 Effect 时不要先问“依赖数组该怎么填”,而应按以下顺序推导:

  1. 哪个外部系统需要同步;
  2. 当前渲染提供了哪些输入;
  3. setup 建立了什么资源;
  4. cleanup 如何完整撤销资源;
  5. 依赖变化时旧同步如何退出;
  6. 异步任务乱序返回时,如何阻止失效结果提交;
  7. 这个问题是否其实应该由事件处理器、派生计算、useSyncExternalStore 或框架数据层解决。

当这些关系清楚后,useEffect 就不再是“什么时候执行代码”的技巧,而是一个描述 React 状态与外部系统之间同步边界的工具。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。