use 是 React 19 的一个 API,在组件渲染时从 Promise 或 context 中读取值。use(promise) 会让组件暂停,直到 Promise resolve,期间显示最近的 <Suspense> 后备内容;use(context) 的作用和 useContext 相同,但可以放在 if 或循环里。
下面的 fetchUser 函数代替了真实的请求。
第一次渲染会先显示一秒“Loading...”,然后显示用户资料。点击 User 2:新的 Promise 处于 pending 状态时,后备内容又会出现。Profile 没有自己的加载状态;它写得就好像数据已经在那里了一样。
use(promise) 的工作原理
当 Profile 调用 use(userPromise) 时:
- 如果 Promise 已经 resolve,
use返回它的值,渲染继续进行。 - 如果它仍处于 pending 状态,React 会停止渲染
Profile,并显示它上方最近的<Suspense>的后备内容。等 Promise 有了结果,React 会再次渲染Profile,这次use就会返回值。 - 如果它被 reject,React 会改为显示最近的错误边界。
React 把结果记在 Promise 对象本身上。这就是为什么下一次渲染时,Promise 必须是同一个对象。
想在下一个用户资料加载期间继续显示旧的资料,就在 transition 中设置 Promise。这样 React 会等待,而不会再次显示后备内容:
<button onClick={() => startTransition(() => setUserPromise(fetchUser(2)))}>User 2</button>
Promise 应该在哪里创建
这是最容易出错的地方。在读取数据的组件里直接写请求调用,看起来很自然,却行不通:
// Do not do this
function Profile({ id }) {
const user = use(fetchUser(id)); // a new promise on every render
return <p>{user.name}</p>;
}
每次渲染都会调用 fetchUser,得到一个新的、处于 pending 状态的 Promise。Profile 挂起。等这个 Promise resolve,React 再次渲染 Profile,又创建了另一个 pending 的 Promise,于是再次挂起。组件永远显示不出数据,网络面板里的请求则越积越多。
要把 Promise 创建在不会随读取它的组件每次渲染而重新执行的地方:
- 在父组件中,保存在 state 里(就像第一个示例那样),或者在事件处理函数中创建。
- 在模块顶层,适用于数据不依赖 props 的情况:在组件上方写
const configPromise = fetchConfig();。 - 在缓存中,以参数作为键,这样同一个 id 会返回同一个 Promise。
- 在框架中:Server Component 可以把 Promise 作为 prop 传给客户端组件,路由 loader 也是如此。这正是
use的设计初衷。
下面是缓存版本,组件可以按 id 请求数据,同时拿到一个稳定的 Promise:
依次点击三个城市,再点回一个已经打开过的城市。控制台对每个城市只记录一次请求,而之前看过的城市会直接显示,不经过后备内容,因为它的 Promise 早已 resolve。删掉 if (!cache.has(city)) 这个检查,组件就又会永远挂起。
真正的缓存还需要让条目过期的办法。数据请求库和框架会处理这件事,这也是为什么大多数应用的 Promise 都来自它们。
use(context),甚至可以放在 if 里
use(SomeContext) 返回的值和 useContext(SomeContext) 相同。区别在于可以在哪里调用它。其他所有 Hook 都必须在顶层运行,并且每次渲染的顺序都相同(见 Hook 规则)。而 use 可以在提前 return 之后、在条件里或在循环里调用。
点击按钮,在两个分支之间切换。把 use(UserContext) 换成 useContext(UserContext),代码就违反了 Hook 规则,因为这个 Hook 会在某些渲染中运行,在另一些渲染中不运行。
use 仍然有限制:只能在渲染组件时或在自定义 Hook 中调用它。在事件处理函数或 Effect 里,要读取 context,就用渲染期间已经拿到的值。
错误处理:用错误边界,而不是 try/catch
当 Promise 被 reject 时,错误会交给最近的错误边界,和渲染错误的处理方式一样。
打开 Post 2,错误边界会显示错误信息。再打开 Post 1,它又能正常显示了:key={id} 让错误边界为每篇文章都使用全新的 state,所以它不会一直显示旧的错误。
你无法用 try/catch 捕获处于 pending 状态或被 reject 的 use。Promise 处于 pending 状态时,use 会通过抛出一个特殊的值来中断渲染,包在外面的 catch 会把它吞掉。捕获到的值是一个错误,其信息以“Suspense Exception: This is not a real error!”开头,而且在开发环境中,React 还会打印“use was called from inside a try/catch block”。如果想显示一个后备值而不是错误界面,就在把 Promise 往下传之前处理它的 reject:
const postPromise = fetchPost(id).catch(() => 'This post could not be loaded.');
use 与在 useEffect 中请求数据的对比
经典的客户端写法是在 Effect 中请求数据,并维护三份 state:
function Profile({ id }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
fetchUser(id).then(
(u) => !ignore && setUser(u),
(e) => !ignore && setError(e),
);
return () => {
ignore = true;
};
}, [id]);
if (error) return <p>Error</p>;
if (!user) return <p>Loading...</p>;
return <p>{user.name}</p>;
}
useEffect | use | |
|---|---|---|
| 请求何时开始 | 第一次渲染显示到屏幕之后 | Promise 创建时,可能在组件渲染之前 |
| 加载状态 | 你自己写的 if (!user) | 最近的 <Suspense> |
| 错误 | 你自己的 error state | 最近的错误边界 |
| 竞态条件 | 由你来防范(ignore) | 组件始终读取交给它的那个 Promise |
| 由谁创建 Promise | 组件自己 | 父组件、缓存或框架 |
Effect 版本是自成一体的,这也是它在没有框架的应用中仍然最常见的原因;请求数据页面对它有完整的介绍。当组件上层的某个地方提前发起请求、再把 Promise 传下来时,use 才能真正发挥优势。
常见问题
React 中的 use Hook 有什么作用?
use(resource) 返回一个 Promise 或 context 的值。传入 Promise 时,组件会挂起直到它 resolve,期间最近的 <Suspense> 显示后备内容;如果它 reject,则改为显示最近的错误边界。
use 可以有条件地调用吗?
可以。use 是唯一一个可以在 if 语句、循环中以及提前 return 之后调用的 Hook。不过它仍然必须在组件或 Hook 中调用,绝不能在事件处理函数或 Effect 中调用。
为什么在组件里写 use(fetch()) 会无限循环?
每次渲染都会创建一个新的 Promise。组件在它上面挂起,Promise resolve 后 React 再次渲染,而这次渲染又创建了另一个处于 pending 状态的 Promise。应该在渲染之外创建 Promise:放在父组件的 state 中、模块顶层、缓存里,或者框架的 loader 中。
可以用 try/catch 包住 use 吗?
不可以。Promise 处于 pending 状态时,use 会通过抛出来中断渲染,捕获它就会破坏这个机制。被 reject 的 Promise 要用错误边界来处理,或者给 Promise 加上 .catch(),让它 resolve 成一个后备值。
应该用 use 取代 useEffect 里的数据请求吗?
use 需要一个只创建一次、可以重复使用的 Promise,而这正是框架和数据请求库提供的。在普通的客户端应用里,在 Effect 中请求数据(或使用 TanStack Query 这样的库)仍然很常见,也完全没问题。