要在 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 时渲染加载动画。