New React 18 and 19 features for promise handling
use, useOptimistic (React 19), useTransition, useDeferredValue, Transition, Actions (React 18), and <Suspense> form a powerful toolkit for managing asynchrony in React. This post walks through how these APIs work in practice and how they fit together, especially useful if you skipped React 18 or 19.
The official React docs explain each API well in isolation; the trick is seeing how the pieces connect.
If you only want a fast reference, jump to the comparison section at the end.
Basic asynchronous work
We start with a simple digital clock that updates every second, a baseline to confirm the UI stays interactive as we add asynchronous work.
Next we add an artificial “heavy” async task: each has an ID and start/complete timestamps, and we delay it a few seconds to show different loading patterns. Clicking “Do heavy work!” starts a task; the result appears when it finishes.
There is no cancellation in this demo. Repeated clicks run each task independently and update the UI when it resolves. We control timing here, so race conditions don’t show up. In real apps you’d need to handle out-of-order results.
The code is a bit long, but most of it is just scaffolding to make the demo clear and interactive.
The most important piece is the cachedWorks map.
We could remove it and create a new promise on every doHeavyWork call with the same ID but then we’d hit a problem. React cannot keep updating state with a new promise each render: each render would produce a different promise instance and trigger infinite re-renders.
In real apps you’d usually use a data library that returns stable promises for a given set of parameters. Here, cachedWorks does the same: it keeps promise identity stable.
Feel free to remove the cache and try the examples. Some results may surprise you.
Refactoring with Suspense and use
We can improve the UI and simplify the code with React’s newer APIs. A first step is to replace the ad-hoc promise handling in HeavyWorkSummary with the use hook and Suspense.
The use hook lets you read a promise as if it were a synchronous value. For pending promises it throws that same promise to the caller. React treats this “throwing a pending promise until the component has finished loading” as suspending.
Rejected promises and other errors are handled by Error Boundaries; this post does not cover them.
If that thrown value isn’t caught, rendering breaks. React’s intended pattern is to wrap any component that uses use inside a Suspense boundary. Suspense renders its fallback until its children stop suspending, then it renders the children.
We wrap HeavyWorkSummary in Suspense, replace the ad-hoc promise handling with use, and move the loading UI into the boundary’s fallback.
As a bonus, the race condition disappears: use does not return stale values when the source promise changes.
Keeping stale data with useDeferredValue and background rendering
The UI works but is not ideal: as soon as we click the button we lose the last completed result. Often we want to keep showing that (with a hint that it is outdated) until new data is ready. Imagine a list that loads more data on demand: showing stale data while loading is usually better than showing nothing until the new data is available.
That’s what useDeferredValue is for.
On first call, useDeferredValue returns whatever you pass in. When the component re-renders because that parameter changed, useDeferredValue still returns the previous value, but it is tightly integrated with Suspense and will also trigger a background render, and this time it will return the new value.
Background rendering only makes sense with Suspense. If that background render causes a child to suspend, React does not commit the new DOM tree (which would show the fallback). Instead it keeps the previous one. So the DOM under the Suspense boundary does not change until the suspending children have finished loading. The user keeps seeing the old content until the new content is ready.
Recall: a component is suspending when it throws a pending promise instead of returning UI. So a background render runs the render code but if something suspends React does not update the DOM tree but keeps the previous UI until the new state is fully loaded.
To see this in action, we add useDeferredValue to the demo and walk through the behaviour.
useDeferredValue demo
In HeavyWorkController we add deferredHeavyWorkId = useDeferredValue(heavyWorkId) and use deferredHeavyWorkId instead of heavyWorkId for the content that can suspend.
Here is the same flow as a sequence of renders and user actions:
- First render: ID
0, deferred ID0. No loading state for ID0, so noSuspenserendered yet.- User clicks the “Do heavy work!” button
- Second render: ID
1, deferred ID still0. Children use deferred ID, so same result and no DOM update.useDeferredValuewill trigger now a background render, no user action required
- Third render (background): deferred ID
1. React renders theSuspenseboundary;HeavyWorkSummarysuspends for ID1. First time for this boundary, so no previous DOM element exists. React updates the DOM, showing thefallback(HeavyWorkLoadingIndicator). Second DOM update.- Let’s assume in this example the user waits until it finishes loading, we will see what happens if they click in a later example
- Promise for ID
1settles.HeavyWorkSummarystopssuspendingsoSuspenseshows it instead offallback. Background render done, React updates the DOM. Fourth render, third DOM update.- Now imagine user clicks the “Do heavy work!” button again
- Fifth render: ID
2, deferred ID still1. Same UI, no DOM update.useDeferredValuetriggers a background Render
- Sixth render (background): deferred ID
2.HeavyWorkSummaryfor ID2suspends (no settled promise yet).Suspenserenders itsfallbackbecauseHeavyWorkSummaryissuspendingso React does not update the DOM but keeps the previous boundary content (result for ID1).- Let’s assume user waits for a bit, we will consider what happens if user clicks in this moment in a different walk through afterwards
- Promise for ID
2settles.HeavyWorkSummarystopssuspending; React updates the DOM with the new result. Seventh render, fourth DOM update. Summary of DOM updates so far:- Initial render with heavy work id 0
- Loading status for heavy work id 1
- Resulting status for heavy work id 1
- Resulting status for heavy work id 2
A few edge cases are worth spelling out.
User clicks again before the first load finishes
- First render: ID
0, deferred ID0, noSuspenserendered yet.- User clicks “Do heavy work!”
- Second render: ID
1, deferred ID0. Same result, no DOM update.useDeferredValuetriggers a background render.
- Third render (background): deferred ID
1.HeavyWorkSummarysuspendsfor ID1; first time for thisSuspenseboundary, so React showsHeavyWorkLoadingIndicator. Second DOM update.- User clicks “Do heavy work!” again before load finishes.
- Fourth render: ID
2, deferred ID1. Same as second render so no DOM changes.useDeferredValuetriggers a background render.
- Fifth render (background): deferred ID
2.HeavyWorkSummaryfor ID2gets a new promise andsuspends;useignores the previous promise. Boundary still showsfallback, so React does not update the DOM.- If the user waits for load to finish we get the new result; if they click again we repeat from step 4 until they wait for a promise to settle.
- Promise for ID
2settles.HeavyWorkSummarystops suspending; React updates the DOM for ID2. Sixth render, third DOM update.
User clicks again while some work is loaded but the latest is still loading
Same idea as above. Foreground render: ID X+1, deferred ID X: same output so no DOM update. useDeferredValue then triggers a background render with deferred ID X+1. HeavyWorkSummary for X+1 gets a new promise and suspends; use drops the previous promise. The boundary keeps showing the old content until the new promise settles, so React does not update the DOM.
Loading hints
Our UI is far from ideal: users have no signal that work is in progress until it finishes. We should show a loading indicator while still keeping the stale data on screen. When the deferred ID and the current ID differ, we are in the foreground render just before the background one, so we can render HeavyWorkLoadingIndicator with the new ID and the user will see it immediately.
If the user clicks several times before load completes, the loading indicator updates to the latest requested ID. Pretty neat!
Manual background rendering with useTransition
useDeferredValue keeps stale data on screen automatically. When you need to control when a background render starts, use useTransition. It returns isPending (true while a Transition is in progress) and startTransition, which you call with a function (React calls this function Action) which may be async.
When you call startTransition, React first re-renders with isPending === true, starting the Transition, and runs the Action. It does not update the DOM again until the Action has finished and no children are suspending. Then it re-renders with isPending === false and the new state, finishing the Transition.
Due to a limitation in React 19, state updates that happen after await inside an Action must be wrapped in their own startTransition call.
The example below swaps useDeferredValue for manual startTransition.
The loading indicator now shows the wrong ID, because React does not re-render until the Transition finishes. useOptimistic fixes that.
Immediate UI updates with useOptimistic
We can start transitions manually and keep the previous UI until async work finishes, and we can show a loading state when there is no prior data. But once a Transition has started, the UI does not update again until it ends so there is no progress or intermediate feedback.
useOptimistic lets you apply state updates immediately from inside an Action, so the UI can reflect in-progress work (e.g. a progress bar for a multi-step operation). The name suggests “optimistic UI“, but it is really about immediate updates from async code.
In our demo we fix the loading indicator by adding optimistic state for the pending heavy-work ID: we update it inside the Action so the user sees the right ID right away, and it is updated again when the transition completes.
Here we could have used other patterns, but in real apps useOptimistic is useful for multi-step feedback or any UI that should update immediately even when the underlying work is async and ongoing.
Comparison and recap
For a quick reference, the APIs are summarised below. The demos above show each one in context; the React docs explain each API in detail, though the big picture is easier to see when they are used together.
use(introduced in React 19)useOptimistic(introduced in React 19)useTransition,Transition,Actions(introduced in React 18)useDeferredValue(introduced in React 18)<Suspense>
<Suspense>
Shows its fallback while any child is suspending (throwing a pending promise).
suspending
A component is suspending when it throws a pending promise instead of returning UI.
useDeferredValue
Works with <Suspense>: when the value changes it triggers a background render and only updates the DOM when children stop suspending. Use it to keep stale data on screen while new data loads.
useTransition
Lets you start a Transition manually. State updates inside the transition do not cause a DOM update until the Transition finishes, so the previous UI stays visible.
Transition
Started via the startTransition function returned by useTransition. Batches state updates: the component that started the Transition does not re-render until the transition finishes (all awaited promises and suspending children are done).
Action: the function you pass to startTransition. It may be sync or async.
Action
See Transition
use
Reads a promise or context; if the promise is pending, the component suspends. Lets you use promises directly under Suspense boundaries.
No replies on “New React 18 and 19 features for promise handling”