跳到主要内容

如何让useEffect支持async/await

面试速答(30 秒版 TL;DR)

  • 先说结论:不能直接把 useEffect 的回调写成 async
  • 原因不是语法不支持,而是 React 约定 useEffect 的返回值只能是:
    • undefined
    • 清理函数 cleanup
  • async function 一定返回 Promise,这会破坏 React 对 cleanup 的处理语义。
  • 正确做法是:在 effect 内部再定义一个 async 函数并立即调用,同时补上取消逻辑。
  • 一句话总结:不是让 useEffect 本身 async,而是让 effect 内部的异步任务用 async/await 写。

一、为什么 useEffect(async () => {}) 不对

下面这种写法很常见,但不推荐:

useEffect(async () => {
const data = await fetchUser(userId)
setUser(data)
}, [userId])

问题在于:

  • async 函数返回的是 Promise
  • React 期待 effect 返回的是 cleanup 函数或什么都不返回

React 不会把这个 Promise 当清理函数执行。

所以这类写法的根本问题不是“能不能 await”,而是 返回值契约不匹配

二、正确写法:effect 内部包一层 async

最常见写法如下:

useEffect(() => {
let cancelled = false

async function load() {
const data = await fetchUser(userId)
if (!cancelled) {
setUser(data)
}
}

void load()

return () => {
cancelled = true
}
}, [userId])

这段代码的关键点有三个:

  1. effect 回调本身仍然是同步函数。
  2. 真正异步逻辑放进内部 async function
  3. cleanup 里要处理取消或忽略过期结果。

三、为什么“取消逻辑”是这题的重点

很多人会答成:

  • useEffect 里套个 async 就行

这不够成熟。

真正工程里要处理两个风险:

1. 组件卸载后异步结果才回来

如果请求回来时组件已经卸载,再调用 setState 会造成无意义更新,老版本 React 里还常伴随警告。

2. 旧请求比新请求更晚返回

例如:

  • 先请求 userId = 1
  • 马上切到 userId = 2
  • 结果 1 的请求更晚返回

如果不做保护,旧结果可能把新结果覆盖掉。

所以成熟答法里一定要补:

  • cancelled 标志
  • AbortController
  • 或请求库自带取消能力

四、更稳的写法:配合 AbortController

如果底层是 fetch,更推荐这样写:

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

async function load() {
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
})
const data = await res.json()
setUser(data)
} catch (error) {
if ((error as DOMException).name !== 'AbortError') {
setError(error as Error)
}
}
}

void load()

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

相比手写布尔标记,这种方式更像真正的“中止请求”,而不只是“忽略结果”。

五、为什么 React 不直接支持 async effect

因为 React 对 effect 的设计重点是:

  • 什么时候执行副作用
  • 什么时候执行 cleanup

如果 effect 回调直接返回 Promise,React 很难把:

  • 异步完成时机
  • cleanup 注册时机

这两件事稳定地绑定起来。

所以 React 并没有把“effect 返回 Promise”设计成标准语义。

六、React 18/19 下还要补的面试点

1. 开发环境 StrictMode 会多跑一轮

开发环境下,React 可能会经历:

  1. 执行 effect
  2. 立刻 cleanup
  3. 再执行一次 effect

如果你的请求逻辑不是幂等的,就可能出现:

  • 重复请求
  • 重复订阅
  • 取消逻辑不完整

所以异步 effect 必须天然支持“先清理再重建”。

2. 不是所有数据获取都该放在 useEffect

如果项目有更高层的数据获取方案,比如:

  • 路由 loader
  • 服务端渲染数据预取
  • 专门的数据缓存库

通常会比手写 useEffect + useState 更稳。

面试里补一句这点,会显得你不是只会背基础 Hook。

七、面试里怎么答最稳

标准答法

useEffect 不能直接写成 async,因为 effect 回调的返回值只能是 cleanup 函数或 undefined,而 async 函数一定返回 Promise,和 React 的约定不匹配。正确做法是在 effect 内部定义并调用 async 函数,把异步逻辑用 async/await 写,同时在 cleanup 里用 cancelled 标记或 AbortController 处理取消,避免组件卸载后更新状态或旧请求覆盖新请求。

八、常见追问

1. 为什么不能直接 return await ...

因为 React 关心的是 cleanup,不是等待你的 Promise 完成。

2. cancelled = trueAbortController 有什么区别?

  • cancelled = true 更像忽略结果
  • AbortController 更像真的通知底层请求中止

3. effect 里一定要用 async/await 吗?

不一定。用 Promise 链也可以。核心不是语法风格,而是:

  • effect 回调不能直接 async
  • cleanup 必须明确

九、易错点 / 坑

  • 直接把 effect 回调写成 async
  • 只会写 async/await,不处理竞态和取消。
  • 忘记 StrictMode 下开发环境会多执行一轮。
  • 把所有数据获取都机械塞进 useEffect

速记要点(可背诵)

  • useEffect 本身不要 async
  • 内部再包一层 async 函数。
  • cleanup 里处理取消、卸载和竞态。
  • 重点不是 await,而是副作用生命周期管理。