Menu

React 用 useEffect 获取数据:加载、错误与竞态

在 React 中获取数据,就是在 useEffect 里发起请求,把加载、错误和数据保存在状态中,并忽略输入变化之后才到达的响应。内容涵盖副作用中的 async/await、AbortController、竞态条件,以及何时改用库。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

要在 React 中获取数据,就在 useEffect 里发起请求,把响应存进状态,并在数据到达之前渲染一条加载提示。把请求依赖的值(比如一个 id)放进依赖数组,这样当它们变化时,副作用会重新获取数据。

本页的示例无法访问网络,所以 fetchUser 是一个假的 API:一个在延迟后 resolve 的 Promise,和 fetch 的行为一样。把 800 改成 3000,就能看到加载文字停留得更久。

使用真实 API 时,副作用的函数体是这样的:

useEffect(() => {
    fetch('https://api.example.com/users/1')
        .then((res) => res.json())
        .then((data) => setUser(data));
}, []);

加载、错误与数据

一个请求有三种结果需要显示在屏幕上:仍在加载、失败或完成。把每种结果都保存在状态中,并在副作用发起新请求时重置它们。

点击 User 3:假 API 返回拒绝,错误信息取代了名字,控制台显示这个错误。finally 代码块在两条路径上都会清除 loading,所以失败的请求永远不会让加载动画一直转下去。

当 fetch 与真实服务器通信时,404 或 500 响应并不会让 Promise 被拒绝。你需要检查 res.ok 并自己抛出错误:

const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();

在副作用中使用 async/await

传给 useEffect 的函数不能是 async。React 要求它什么都不返回,或者返回一个清理函数,而 async 函数总是返回一个 Promise。

// Wrong: the effect returns a promise
useEffect(async () => {
    const data = await fetchUser(id);
    setUser(data);
}, [id]);

// Right: define an async function inside and call it
useEffect(() => {
    async function load() {
        const data = await fetchUser(id);
        setUser(data);
    }
    load();
}, [id]);

竞态条件

当 id 快速变化时,会有两个请求同时在途。没有任何东西能保证它们按顺序返回。如果较早的请求更慢,它会最后到达,覆盖掉用户最近选择的那个 id 的数据。

解决办法是一个 ignore 标志。副作用的每次运行都有自己的标志,它的清理函数会把标志设为 true。因为 React 会在下一次运行之前执行清理函数,过期那次运行收到的响应会看到 ignore === true,然后被丢弃。下面的代码块把同一个个人资料渲染了两次,一个没有标志,一个有标志:

点击按钮并等待。用户 2 的快速响应先到达,所以两个个人资料都会打印日志并显示 Grace。点击后大约一秒半,用户 1 的慢速响应到达:没有标志的那个切换成了 Ada,尽管当前选中的是用户 2;有标志的那个则打印 dropped stale Ada,继续显示 Grace。

同样的 bug 也会出现在搜索框、标签页以及任何由服务器过滤的列表中。上面的加载示例为了简短省略了这个标志,但副作用中的每次获取都应该加上它,或者使用下文的中止方式。

用 AbortController 取消请求

ignore 标志会丢弃过期的响应,但请求本身仍会运行到结束。使用真实的 fetch 时,你可以取消它。在副作用中创建一个 AbortController,把它的 signal 传给 fetch,然后在清理函数中调用 abort():

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

    async function load() {
        try {
            const res = await fetch(`/api/users/${id}`, { signal: controller.signal });
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            setUser(await res.json());
        } catch (err) {
            if (err.name === 'AbortError') return; // cancelled on purpose
            setError(err);
        }
    }

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

被中止的 fetch 会以 AbortError 拒绝,所以 catch 代码块会跳过它,而不是把它当成失败显示出来。中止同样适用于卸载:组件被移除时,它的清理函数会取消请求。

封装成自定义 Hook

加载、错误和竞态保护在每个获取数据的组件里都一样。把它们放进一个自定义 Hook,每个组件只需一行就能拿到数据。

fetchPosts 定义在组件外面,所以它的引用永远不变,放进依赖数组是安全的。如果定义在 App 里面,它每次渲染都是一个新函数,副作用会在每次渲染后运行,而且由于副作用会设置状态,它将永远不停地获取数据。

把 500 改成 2000,然后连续点击两次按钮:控制台显示两个请求,但只会出现你最后选中的那个主题的帖子。

在事件处理函数中获取数据

副作用用于组件因为显示在屏幕上而需要的数据:个人资料页面加载它的个人资料。当请求是因为用户做了某件事而发生时,比如点击搜索或保存,就在事件处理函数中发起它。你清楚地知道是什么触发了它,而且无关的状态变化不会让它重新运行。

async function handleSubmit(e) {
    e.preventDefault();
    setStatus('saving');
    const res = await fetch('/api/notes', { method: 'POST', body: JSON.stringify({ text }) });
    setStatus(res.ok ? 'saved' : 'error');
}

常见错误

遗漏依赖。 一个读取了 id 却写着 [] 的副作用,会永远只获取第一个用户。把请求用到的每个值都列出来。

在组件函数体中获取数据。 副作用之外的 fetch 会在每次渲染时运行,如果它还会设置状态,就会陷入循环。

以为 fetch 出错时会拒绝。 它只在网络失败时才拒绝。要检查 res.ok。

忘记重置加载状态。 当 id 变化时,要把 loading 重新设为 true,否则旧数据会留在屏幕上,看不出新数据正在路上。

何时使用库或框架

对于少量请求,在副作用中获取数据就够了。但它没有缓存:同一个个人资料打开两次,就会获取两次。它也不会在组件之间共享数据、失败时重试,或者在标签页重新获得焦点时重新获取。库会替你做这些事:

import { useQuery } from '@tanstack/react-query';

function Profile({ id }) {
    const { data, error, isPending } = useQuery({
        queryKey: ['user', id],
        queryFn: () => fetch(`/api/users/${id}`).then((res) => res.json()),
    });

    if (isPending) return <p>Loading...</p>;
    if (error) return <p>{error.message}</p>;
    return <p>{data.name}</p>;
}

TanStack Query 和 SWR 是客户端数据的常见选择。框架则更进一步,在页面到达浏览器之前就在服务器上加载数据:Next.js 使用服务器组件,React Router 使用 loader。这避免了加载时的闪烁,也避免了父组件的数据必须先到达、子组件才能开始获取所形成的请求链。

React 19:use() 与 Suspense

React 19 新增了 use,它在渲染期间读取一个 Promise。组件会挂起,直到 Promise 被 resolve,在此期间由最近的 <Suspense> 边界显示后备内容,所以组件本身不需要加载状态:

import { use, Suspense } from 'react';

function Profile({ userPromise }) {
    const user = use(userPromise);
    return <p>{user.name}</p>;
}

<Suspense fallback={<p>Loading...</p>}>
    <Profile userPromise={userPromise} />
</Suspense>

Promise 必须在组件外部创建(由框架、缓存或父组件创建),而不是在读取它的那次渲染中创建,否则每次渲染都会发起一个新请求。use Hook 一页用可运行的示例讲解了这一点。

常见问题

如何在 React 组件加载时获取数据?

在 useEffect 中发起请求,把它依赖的值放进依赖数组(没有就写 []),再用 useState 把结果存进状态。在数据到达之前渲染一条加载提示。

为什么 useEffect 的回调不能是 async?

async 函数总是返回一个 Promise,而 React 要求副作用要么什么都不返回,要么返回一个清理函数。在副作用内部写一个 async 函数,然后立即调用它。

React 获取数据时的竞态条件是什么?

当输入快速变化时,会有两个请求同时在途,较早的那个可能最后才返回,覆盖掉较新的数据。在副作用的清理函数中设置一个 ignore 标志,标志被设置时跳过 setState,或者用 AbortController 取消请求。

获取数据应该用 useEffect 还是库?

useEffect 适合小型应用,也值得弄懂。需要缓存、去重、重试和重新获取时,TanStack Query、SWR 这样的库,或 Next.js 等框架内置的数据加载,能省去你自己编写这些逻辑。

如何在数据加载时显示加载动画?

在状态中保存一个 loading 布尔值(或一个 status 字符串),请求开始前设置它,请求结束时清除它,成功和出错两条路径都要清除。它为 true 时渲染加载动画。

Coddy 编程语言插图

和 Coddy 一起学编程

开始