Suspense + use() 实战:优雅处理异步与加载态

Suspense + use() 实战:优雅处理异步与加载态
Suspense use() 实战:优雅处理异步与加载态写 React 数据请求,十有八九会写成这样:一个data、一个loading、一个error,三个 state 互相牵制,组件顶部一坨if (loading) return .../if (error) return ...。请求一多,每个组件都重复这套模板,看着就烦。React 19 稳定的use()API 配合Suspense,能把「加载中」和「出错」这两件事从组件里彻底抽出去。这篇我们从传统写法的痛点讲起,一步步换成use(),并说清楚几个真正会踩的坑。传统写法:三个 state 的重复劳动function UserProfile({ userId }) { const [user, setUser] useState(null); const [loading, setLoading] useState(true); const [error, setError] useState(null); useEffect(() { let cancelled false; setLoading(true); fetch(/api/users/${userId}) .then((r) r.json()) .then((data) { if (!cancelled) setUser(data); }) .catch((e) { if (!cancelled) setError(e); }) .finally(() { if (!cancelled) setLoading(false); }); // 清理:userId 变化时避免旧请求覆盖新数据 return () { cancelled true; }; }, [userId]); if (loading) return Spinner /; if (error) return ErrorBox error{error} /; return h1{user.name}/h1; }这段能跑,但问题很明显:每个要请求数据的组件都得复制这套loading/error/cancelled模板;加载态和错误态跟业务渲染混在一起;cancelled这种防竞态的细节还特别容易忘。换成 use():把加载和错误交给框架use()可以直接「读取」一个 Promise。如果 Promise 还没 resolve,组件会挂起(suspend),最近的Suspense边界接管显示 fallback;如果 Promise reject,错误会向上抛给最近的错误边界(Error Boundary)。import { use, Suspense } from react; function UserProfile({ userPromise }) { // use() 读取 promise:pending 时挂起,reject 时抛给错误边界 const user use(userPromise); return h1{user.name}/h1; } function Page({ userId }) { // 注意:promise 在父组件创建后传入,不在 UserProfile 内部创建 const userPromise useMemo(() fetchUser(userId), [userId]); return ( Suspense fallback{Spinner /} UserProfile userPromise{userPromise} / /Suspense ); }UserProfile里没有loading、没有error、没有useEffect,只剩「拿到 user 就渲染」这一件事。加载态由Suspense管,错误态由错误边界管。最容易踩的坑:不要在渲染里直接创建 Promise新手最常犯的错是这样写:// 反例:每次渲染都新建 promise,无限挂起 / 无限请求 function UserProfile({ userId }) { const user use(fetch(/api/users/${userId}).then(r r.json())); return h1{user.name}/h1; }问题在于:组件挂起后恢复会重新渲染,fetch(...)又生成一个全新的 Promise,use()看到新 Promise 继续挂起……要么无限 loading,要么疯狂发请求。Promise 必须在渲染之外保持稳定的身份。两种正确姿势:在父组件用useMemo缓存(上面的例子),userId不变就不重建。更推荐:用带缓存的数据层(React Query、SWR,或框架的cache()),由它保证同一 key 返回同一个 Promise。在 Next.js App Router 里,直接用 Server Component 里的async/await通常更省事;use()主要用在客户端组件需要读取从服务端传下来的 Promise,或需要条件性读取的场景。use() 的独门能力:可以在条件里调用普通 Hook 不能放在if里,但use()可以。这让「按条件读取」变得干净:function Message({ messagePromise, show }) { if (!show) { return p点击查看消息/p; } // 合法!use 允许在条件分支中调用,普通 Hook 不行 const message use(messagePromise); return p{message}/p; }它也能读 Context,写法比useContext更灵活(同样能放条件里)。配套:错误边界兜住 rejectSuspense只管加载,错误得靠错误边界。目前错误边界还得用 class 组件(或react-error-boundary库):import { ErrorBoundary } from react-error-boundary; function Page({ userId }) { const userPromise useMemo(() fetchUser(userId), [userId]); return ( ErrorBoundary fallback{p加载失败,请刷新/p} Suspense fallback{Spinner /} UserProfile userPromise{userPromise} / /Suspense /ErrorBoundary ); }现在整条链路清爽了:ErrorBoundary兜错误,Suspense兜加载,UserProfile只管成功渲染。这三层职责分明,新增任何一个数据组件都不用再抄loading/error模板。小结传统data/loading/error三 state 写法重复、易漏防竞态,加载与业务渲染耦合。use()读取 Promise:pending 挂起交给Suspense,reject 抛给错误边界,组件只写成功路径。最大的坑:别在渲染函数里直接use(fetch(...)),Promise 会每次重建导致无限挂起;要用useMemo或带缓存的数据层稳定 Promise 身份。use()可以在if分支里调用,这是普通 Hook 做不到的,适合条件性读取数据/Context。完整方案 错误边界 Suspense use(),三层职责分离。一句话记忆:use()让组件只负责「有数据时怎么渲染」,加载和报错都外包给了 Suspense 和错误边界——但前提是 Promise 身份必须稳定。