React provides a declarative approach to async rendering through Suspense, Error Boundaries, and the use() API.
Suspense displays fallback UI while a child component is waiting for a resource supported by Suspense.
import { Suspense } from 'react'function App() {return (<Suspense fallback={<div>Loading phone details...</div>}><PhoneDetails /></Suspense>)}
When PhoneDetails suspends, React displays the fallback and retries rendering when the resource becomes available.
Important: Suspense does not automatically detect ordinary fetch() calls or manage every loading state.
Error Boundaries display fallback UI when a descendant throws an error during rendering.
import { ErrorBoundary } from 'react-error-boundary'import { Suspense } from 'react'function App() {return (<ErrorBoundaryfallback={<div>Oops, something went wrong.</div>}><Suspense fallback={<div>Loading...</div>}><PhoneDetails /></Suspense></ErrorBoundary>)}
Suspense handles pending resources.ErrorBoundary handles rendering errors.use() APIReact 19 introduced use() for reading resources such as Promises during rendering.
import { use } from 'react'function PhoneDetails({phonePromise,}: {phonePromise: Promise<{ name: string }>}) {const phone = use(phonePromise)return <div>{phone.name}</div>}
If the Promise is pending, the component suspends. If it rejects, React can propagate the error to the nearest Error Boundary.
Unlike ordinary Hooks, use() can be called inside conditions and loops, subject to React’s rules.
Key distinction: use() reads a Promise; it does not initiate fetching or cache the result.
Avoid creating a new Promise on every render:
function ProfileDetails({ username }: { username: string }) {const favorites = use(getFavorites(username))return <div>{favorites.length} favorites</div>}
If getFavorites() creates a new Promise each time, repeated suspension may occur.
Instead, create the Promise in a supported data-fetching layer or cache it by key:
const userCache = new Map<string, Promise<User>>()function getUserPromise(id: string) {let promise = userCache.get(id)if (!promise) {promise = fetch(`/api/user/${id}`).then((res) => {if (!res.ok) {throw new Error('Failed to fetch user')}return res.json() as Promise<User>})userCache.set(id, promise)}return promise}
This is a simplified example. Production caches should also consider rejected Promises, retries, invalidation, and user-specific data isolation.
Consider two independent requests:
function ProfileDetails({ username }: Props) {const favorites = use(getFavorites(username))const friends = use(getFriends(username))return <Profile favorites={favorites} friends={friends} />}
If each function starts a request, rendering may suspend on the first Promise before starting the second request. Recreating Promises on retries can make this even worse.
Start both requests before rendering the component that consumes them:
function ProfilePage({ username }: Props) {const favoritesPromise = getFavorites(username)const friendsPromise = getFriends(username)return (<Suspense fallback={<div>Loading profile...</div>}><ProfileDetailsfavoritesPromise={favoritesPromise}friendsPromise={friendsPromise}/></Suspense>)}function ProfileDetails({favoritesPromise,friendsPromise,}: {favoritesPromise: Promise<Favorite[]>friendsPromise: Promise<Friend[]>}) {const favorites = use(favoritesPromise)const friends = use(friendsPromise)return <Profile favorites={favorites} friends={friends} />}
Both requests start before ProfileDetails reads either Promise. In production, ensure the parent or data-fetching layer preserves Promise identity across rerenders.