You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React StrictMode下useEffect错误读取渲染周期状态的问题及疑问

React 18 StrictMode下useEffect清理函数误读后续渲染state导致请求中止

前置背景

  • 明确StrictMode会触发render→unmount→render的双渲染校验流程
  • 明确StrictMode要求组件保持纯函数特性
  • 已排查相关问题但未找到适配的解决方案

问题描述

组件通过useState存储包含AbortController.signal的网络请求实例,useEffect的清理函数负责中止当前渲染周期的请求。但在StrictMode的首次卸载(对应第一次渲染)流程中,清理函数读取到了第二次渲染的state,导致第二次渲染的请求被意外中止。预期清理函数应绑定对应渲染周期的state,仅中止当前周期的请求。

复现代码

import { Suspense, useEffect, useState } from 'react';

// 包装Promise为可挂起对象
enum Status {
  Pending,
  Fulfilled,
  Rejected,
}

const Suspendable = <T,>(promise: Promise<T>): (() => T) => {
  let status: Status = Status.Pending;
  let error: Error;
  let result: T;
  const suspender = promise.then((r: T): void => {
      status = Status.Fulfilled;
      result = r;
  })
      .catch(err => {
          status = Status.Rejected;
          error = err;
      });

  return () => {
      switch(status) {
          case Status.Pending:
              throw suspender;
          case Status.Rejected:
              throw error;
          case Status.Fulfilled:
              return result;
          default:
              throw Error(`Unexpected arg ${status}`);
      }
  }
};

// 封装fetch请求
const Req = (uri: string, config: RequestInit={}): Promise<string> => new Promise<string>((resolve, reject) => {
  fetch(uri, config)
      .then(resp => resp.text())
      .then(resolve)
      .catch(reject);
});

// 渲染内容组件
const Renderer = ({ getResponse }: { getResponse: () => string }) => {
  console.log(`Renderer start`);
  const response = getResponse();
  console.log(`Renderer get response, return component`);
  return <pre>{response}</pre>;
}

// State类型定义
interface Result {
  suspendable: () => string;
  abortController: AbortController;
  retryKey: number;
}

const App = () => {
  const [controller, setResult] = useState<Result>(() => {
    const abortController = new AbortController();
    const retryKey = Math.floor(Math.random() * 100000);
    console.log(`create controller ${retryKey}`)
    return {
        abortController,
        retryKey,
        suspendable: Suspendable<string>(Req(`/`, { signal: abortController.signal }))
    };
  });

  useEffect(() => {
    return () => {
      console.log(`clean up ${controller.retryKey}`);
      controller.abortController.abort();
    }
  })

  return <>
    <Suspense fallback={<p>Loading</p>}>
        <Renderer getResponse={controller.suspendable} />
    </Suspense>
    <p>-- Parent Node</p>
  </>;
}

export default App;

预期行为

  • StrictMode首次渲染时,页面报错可被ErrorBoundary捕获
  • 首次卸载的清理函数仅中止第一次渲染生成的请求
  • 第二次渲染时Suspense正常显示加载状态,请求完成后渲染内容

实际异常表现

控制台日志输出:

create controller 8343  # 第一次渲染生成的实例
create controller 69996  # 第二次渲染生成的实例
Renderer start
clean up 69996  # 第一次卸载的清理函数操作了第二次渲染的实例
Renderer start
Uncaught DOMException: The operation was aborted.   # 预期内(第一次请求被中止)
Renderer start
Uncaught DOMException: The operation was aborted.   # 意外(第二次请求被误中止)
clean up 69996  # 重复执行清理

已尝试方案及疑问

尝试通过useRef标记是否已卸载来控制清理逻辑:

const hasUnmounted = useRef(false)

useEffect(() => {
  return () => {
    if(hasUnmounted.current){
        // 执行请求中止逻辑
    }
    hasUnmounted.current = true;
  }
})

但存在以下疑问:

  1. 原代码中为什么会出现清理函数读取后续渲染state的问题?
  2. 为什么需要通过useRef手动控制清理逻辑,而不能依赖React的渲染周期自动绑定对应state?

内容的提问来源于stack exchange,提问作者TylerTemp

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.14 15:02:32