React 18 introduced Suspense for data fetching, but it only provided half the picture: the boundary that waits, with no official way to tell it what to wait for. Now React 19 has brought in use(promise), which exposes more of the interface to us, but there's still some work to do.
How it worked before
A <Suspense> boundary shows its fallback while anything inside it isn't ready, and shows the real content once everything is. The components inside are written as if their data is already there: no loading flags, no three-way if. Wrap a change in a transition and React keeps the old screen up instead of the fallback until the new one is ready.
What React 18 didn't document was how a component tells the boundary it's waiting. The mechanism was to throw a promise while rendering. React caught it, showed the fallback, and rendered the component again when the promise settled. That had a catch: a component that suspends the first time it renders keeps nothing, because React throws that render away along with its state and refs. So the promise had to live outside the component, in a cache, or every attempt would start a new request.
Doing it directly, without a framework, meant building that half ourselves. The React 18 article does it with a few small pieces: a cache keyed by what's being loaded, with a record of each request's state, and a read that throws the promise until the answer is in:
function read(key, fetcher) {
const entry = preload(key, fetcher)
if (entry.status === 'pending') throw entry.promise
if (entry.status === 'error') throw entry.error
return entry.value
}
Around that: an error boundary for failures, preload in event handlers to start requests early, invalidate to throw away a stale copy after a save, and transitions to keep the old screen up meanwhile.
What React 19 adds
use(promise)
React 19 makes the waiting part official. use takes a promise, gives back its value if it has one, and suspends the component if it hasn't:
import { use } from 'react'
function LevelPanel({ levelPromise }) {
const level = use(levelPromise)
return <LevelSettings level={level} />
}
If the promise is rejected, the error goes to the nearest error boundary, as a thrown error did before. That's also why use can't go inside a try/catch: it works by throwing, so a catch would swallow the suspension.
Despite the name, use isn't a hook. It has to be called while a component or hook is rendering, but it can be called inside an if or a loop, so a component can return early before it reads anything:
function LevelPanel({ levelPromise }) {
if (!levelPromise) return <p>Pick a level.</p>
const level = use(levelPromise)
return <LevelSettings level={level} />
}
use reads context too, the same way, which makes it a conditional useContext.
Fallbacks show sooner
React 19 also changed what happens inside a boundary when something suspends. React 18 rendered the rest of the boundary's children before showing the fallback. React 19 shows the fallback straight away, then renders the suspended siblings afterwards, to "pre-warm" their requests. The spinner appears sooner, and components that start their own requests while rendering still get them going, a moment later than before.
Async transitions
Transitions can now be async functions. isPending stays true for the whole thing, so the pending state for a save, which React 18 left to us, now comes from the same useTransition that keeps the old screen up. React calls these functions Actions, and builds useActionState, useOptimistic and form actions on top of them.
What you still have to do
There's one rule use depends on: it has to be given the same promise every time the component renders. And for the same reason as before, a component can't keep that promise itself. A promise made while rendering is a new promise on every attempt:
function LevelPanel({ id }) {
// a new request on every attempt, so it never gets past the fallback
const level = use(fetchLevel(id))
return <LevelSettings level={level} />
}
React notices this and warns: "A component was suspended by an uncached promise. Creating promises inside a Client Component or hook is not yet supported, except via a Suspense-compatible library or framework." The React team's own advice is to get the promise from a library or framework that caches promises, and they say that "in the future we plan to ship features to make it easier to cache promises in render".
So React 19 provides the part that throws, and the cache is still ours to write, or a library's. React does now remember what each promise it's given resolved to, so once a promise has settled, giving use the same promise again gives back its value at once without suspending. That makes our side of it smaller: a cache of promises is enough.
A few things are still as they were:
- React 19 has a
cachefunction, but it's only for Server Components, where it lasts for one request on the server. It doesn't help in the browser. - Error boundaries are still class components. There's no hook for them. What's new is
onCaughtErrorandonUncaughtErroroncreateRoot, for reporting errors in one place. - Deciding when cached data is out of date, and loading it again, is still entirely ours.
With Server Components, which need a framework, there's another source of stable promises: a server component starts the request and passes the promise down as a prop, and the client component calls use on it. That's the case use was built for. Without a framework, it's back to a cache.
Upgrading the React 18 way
Code that throws promises itself still works in React 19. React's own source now files a thrown promise under "deprecated", but it doesn't warn about it, and it's how the libraries kept working through the upgrade. Even so, moving to use takes code away rather than adding it.
The cache
The cache no longer needs a record of each request's state, because React keeps that on the promise. It just stores promises:
const cache = new Map()
function load(key, fetcher) {
if (!cache.has(key)) cache.set(key, fetcher())
return cache.get(key)
}
function invalidate(key) {
cache.delete(key)
}
read is gone. The component gets the promise from the cache and hands it to use:
function LevelPanel({ id }) {
const level = use(load('level:' + id, () => fetchLevel(id)))
return <LevelSettings level={level} />
}
The key still does all the work it did before: two components asking for the same level share one request, and an answer for an old id is never read.
Two requests at once
With read, loading two things side by side meant remembering to preload the second before reading the first. Now getting a promise and waiting for it are separate steps, so it falls out naturally: get both promises, then wait for each:
function LevelPanel({ id }) {
// both requests start before we wait for either
const levelPromise = load('level:' + id, () => fetchLevel(id))
const spritesPromise = load('sprites:' + id, () => fetchSprites(id))
const level = use(levelPromise)
const sprites = use(spritesPromise)
...
}
Preloading
preload becomes load, called for its side effect. In an event handler it starts the request before the component that needs it has rendered, and keeping the old screen up is the same transition as before:
function select(id) {
// start loading now, and keep the old level up meanwhile
load('level:' + id, () => fetchLevel(id))
startTransition(() => setSelectedId(id))
}
This matters a little more in React 19. A request that starts while a component renders now starts after the fallback has been shown, so starting it in the event handler wins by more than it used to.
Errors
The error boundary doesn't change at all: a rejected promise reaches it just as a thrown error did, and resetting it when the level changes still tries again.
Saving
Refreshing after a save becomes an Action. The save, throwing away the stale copy and rendering again all happen in one async transition, and isSaving is true until the fresh level is on screen, which is what a Save button needs to disable itself:
const [version, setVersion] = useState(0)
const [isSaving, startSaving] = useTransition()
function save(level) {
startSaving(async () => {
await saveLevel(level)
invalidate('level:' + level.id)
// an update after an await needs its own transition
startSaving(() => setVersion((v) => v + 1))
})
}
The second startSaving is needed. React loses track of the transition across an await, so an update after one has to be wrapped again, or the panel drops back to the fallback while the fresh copy loads. The React team call this a known limitation that they plan to fix.
Things to know
- Don't look inside the promise yourself. React records its result on the promise, but that's for React; always go through
use. - A failed request stays in the cache, so a "Try again" button should call
invalidateon its key before resetting the error boundary. - The cache still never forgets anything. A real one needs a size limit, or entries that expire.
- With server rendering, the cache still has to belong to each request, and the data still has to reach the browser so hydration doesn't load it all again. This is where Server Components and the frameworks take over.
So React 19 closes half the gap. Suspending is now official, smaller and easier to get right, and a whole layer of bookkeeping has gone. What's left is the cache: one promise per thing being loaded, kept somewhere that outlives the components reading it, and thrown away when it's out of date. That's a few lines rather than a library, but it's still ours to write.
See also
- Suspense for data fetching in React 18 - What React 18 provided, how the libraries filled the gap, and the cache this article upgrades
- Taming React 1: composable state with useStateField - A useState-style pair for any field of a big state object
- Taming React 2: mutable variables - Keeping a value and its setter together in one variable