第02章:AI帮我用了useState但没有处理异步状态

第02章:AI帮我用了useState但没有处理异步状态

“AI 给你写了一个带有 loading/error/data 三个 useState 的数据获取组件。看起来很完整,但仔细一想:loading 设为 true,然后 await fetch,然后 loading 设为 false——如果组件在请求途中被卸载了呢?如果两个请求同时在飞,先发的后回来了呢?如果请求出错,error 设了,但 loading 没有置为 false 呢?这些竞态条件(Race Condition)AI 几乎不会主动提醒你。”


ℹ️ 版本说明:本章基于 React 19.2.6

2.1 AI默认会生成什么

// AI 通常给你的代码(基础但有竞态问题)
function SearchResults({ query }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    setLoading(true);
    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(setData)  // ← 可能更新已卸载的组件!
      .catch(setError)
      .finally(() => setLoading(false));
  }, [query]);
  
  if (loading) return <Spinner />;
  if (error) return <div>Error: {error.message}</div>;
  return <div>{data?.results?.map(r => <Item key={r.id} {...r} />)}</div>;
}

2.2 AI通常遗漏的4个坑

⚠️ 坑1:组件卸载后设置 state(内存泄漏警告)

// 问题:请求飞出去了,但用户已经切换页面,组件已卸载
// 请求回来后还调用 setState,React 会警告(虽然 React 18+ 不会内存泄漏,但逻辑错误)
useEffect(() => {
  fetch('/api/data').then(data => {
    setData(data);  // 组件已卸载!无意义的状态更新
  });
}, []);

// 修复:用 AbortController 取消请求
useEffect(() => {
  const controller = new AbortController();
  
  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(setData)
    .catch(err => {
      if (err.name !== 'AbortError') setError(err);  // 忽略取消引起的错误
    });
    
  return () => controller.abort();  // cleanup:组件卸载时取消请求
}, []);

⚠️ 坑2:竞态条件(先发的请求后回来)

// 问题:用户快速输入 "a" → "ab" → "abc"
// 发出了3个请求,但 "a" 的响应可能在 "abc" 之后回来
// 最终展示的是 "a" 的结果,而用户看的是 "abc"!
useEffect(() => {
  fetch(`/api/search?q=${query}`)
    .then(res => res.json())
    .then(setData);  // ← 不知道这是第几个请求的结果
}, [query]);

// 修复方案1:用 AbortController 取消过期请求
useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setData)
    .catch(err => { if (err.name !== 'AbortError') setError(err); });
  return () => controller.abort();  // 新请求来时,取消旧请求
}, [query]);

// 修复方案2:版本号(ignore flag)
useEffect(() => {
  let current = true;  // 是否是最新的请求
  fetch(`/api/search?q=${query}`)
    .then(res => res.json())
    .then(data => {
      if (current) setData(data);  // 只有最新的请求才更新状态
    });
  return () => { current = false; };  // 旧请求返回时 current 已是 false
}, [query]);

⚠️ 坑3:error 时 loading 没有置为 false

// 问题:常见的写法(Promise 链)
useEffect(() => {
  setLoading(true);
  fetch('/api/data')
    .then(res => res.json())
    .then(data => {
      setData(data);
      setLoading(false);  // 只有成功才置为 false
    })
    .catch(err => {
      setError(err);
      // ← 忘记 setLoading(false)!错误后页面永远转圈
    });
}, []);

// 修复:finally 总是执行
fetch('/api/data')
  .then(res => res.json())
  .then(setData)
  .catch(setError)
  .finally(() => setLoading(false));  // ← 无论成功失败都置为 false

⚠️ 坑4:React 19 建议:用 useActionState 或 use() 替代手写异步状态

// React 19 之前:手写 loading/error/data(容易出错)
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

// React 19 方案A:use() hook(数据获取)
// 不再需要 loading/error state!由 Suspense/ErrorBoundary 处理
const data = use(fetchData(id));

// React 19 方案B:useActionState(表单/用户触发的操作)
const [state, submitAction, isPending] = useActionState(
  async (prevState, formData) => {
    try {
      const result = await submitForm(formData);
      return { data: result, error: null };
    } catch (err) {
      return { data: null, error: err.message };
    }
  },
  { data: null, error: null }
);
// isPending 自动管理 loading 状态!不需要手写

2.3 更好的提示词

提示词 P01:修复竞态条件

使用时机:数据获取偶尔显示错误的结果,快速切换参数时尤其明显

帮我修复 React 19.2.6 组件的数据获取竞态条件。

当前代码:
[粘贴组件代码]

问题现象:
[快速切换 query/id 等参数时,偶尔展示的是旧参数的数据]

需要:
1. 用 AbortController 取消过期请求(推荐方案)
2. 修复 error 时 loading 没有置为 false 的问题(如果有)
3. 修复组件卸载后 setState 的问题(如果有)

可选:
- 如果这是一个搜索场景(用户输入),加上 debounce(300ms)
- 如果可以改造成 use() hook,给我展示新写法对比

基于 React 19.2.6。

提示词 P02:用 useActionState 管理用户操作状态

使用时机:按钮点击、表单提交等用户触发的异步操作

帮我用 React 19.2.6 的 useActionState 重写这个组件的异步操作。

当前代码(手写 loading/error state):
[粘贴代码]

操作类型:[表单提交 / 按钮点击 / 批量删除]

需要:
1. useActionState 的基本用法:
   const [state, submitAction, isPending] = useActionState(action, initialState)
   - state: 上次操作的结果(成功/失败的数据)
   - submitAction: 触发操作的函数(可以直接作为 form action)
   - isPending: 是否正在执行(自动管理!)

2. 乐观更新(如果需要):配合 useOptimistic(第5章)

3. Server Actions(如果是 Next.js):
   - action 函数如何标记为 'use server'
   - 如何在 form 里直接使用 Server Action

4. 错误处理:如何在 state 里区分成功和失败状态

给我完整的重写代码。

基于 React 19.2.6 + [Next.js 15.x / Vite]。

提示词 P03:自定义数据获取 Hook(带取消和防抖)

使用时机:多个地方需要相同的数据获取逻辑

帮我写一个 React 19.2.6 自定义 Hook,用于数据获取,包含:

功能需求:
1. 参数变化时自动重新请求(带竞态保护)
2. 组件卸载时自动取消请求(AbortController)
3. 可选:防抖(适合搜索场景)
4. 返回:{ data, loading, error, refetch }

使用场景:
- 搜索(防抖300ms + 竞态保护)
- 详情页(参数变化时重新加载)
- 列表页(支持手动 refetch)

Hook 签名(参考):
const { data, loading, error, refetch } = useFetch('/api/users', {
  params: { page, search },
  debounce: 300,  // 可选
  enabled: !!search,  // 条件触发
});

注意:
- 也要展示 React 19 use() hook 方案的对比
- 什么情况下用自定义 Hook,什么情况下用 use()?

基于 React 19.2.6。

2.4 验收清单

检查项 验证方法 AI辅助
所有 fetch 都有 AbortController 代码里有 controller.abort() cleanup 用 P01 修复
error 时 loading 置为 false 模拟网络错误,验证 spinner 消失 用 P01 修复
竞态保护:快速切换参数 DevTools Network 旧请求已取消(canceled) 用 P01 修复
用户操作用 useActionState 代码里有 useActionState,无手写 loading 用 P02 改造
数据获取考虑用 use() hook 新组件用 use() 而不是 useEffect 用 P02 对比
有 Suspense + ErrorBoundary 包裹 use() 组件外有对应的边界处理 第9章

2.5 本章小结

如果你只记一件事:所有用 useEffect 发起请求的地方,都要加 cleanup 函数取消请求:return () => controller.abort()。这一行代码解决了竞态条件和组件卸载后 setState 两个问题。

异步状态管理的三个层次

  1. 取消和竞态保护(AbortController + cleanup):每个 useEffect 里的 fetch 都要有取消逻辑,防止旧请求覆盖新结果
  2. React 19 新 API(useActionState + use()):用户触发的操作用 useActionState 省去手写 loading/error,数据获取用 use() hook
  3. 封装成 Hook 或用库(useFetch / React Query / SWR):项目里有多个数据获取场景时,统一封装,不要在每个组件里重复写 AbortController

→ 第3章:AI不知道什么时候用Server Components