HomeAbout Me

React Suspense 2: Dynamic Promises

By Daniel Nguyen
Published in React JS
June 03, 2025
4 min read
React Suspense 2: Dynamic Promises

Dynamic Promises

Promise caching

Fetching data in our components is great, but this would be a problem:

async function fetchUser() {
const response = await fetch('/api/user')
const user = await response.json()
}
function NumberInfo() {
const [count, setCount] = useState(0)
const userInfo = use(fetchUser())
const increment = () => setCount((c) => c + 1)
return (
<div>
Hi {userInfo.name}! You have clicked {count} times.
<button onClick={increment}>Increment again!</button>
</div>
)
}

The problem is every time you click the button, fetchUser will be called again triggering a new fetch request for the user which is not only inefficient, but it would trigger the NumberInfo component to suspend which would render the suspense boundary and result in a pretty janky experience even if your user endpoint was fast and the user had a fast network. This is not a good user experience.

The solution to this is to add caching to our fetchUser function. That way, if the fetchUser function is called again, we can return the same promise that was returned previously:

let userPromise
function fetchUser() {
userPromise = userPromise ?? fetchUserImpl()
return userPromise
}
// "impl" is short for "implementation"
async function fetchUserImpl() {
const response = await fetch('/api/user')
const user = await response.json()
return user
}

By returning the same promise, React will keep track of that specific promise and it knows whether it’s already resolved and needs to suspend or not.

Of course, this is a simple example. Caching like this normally involves a lot more thought. For example, what if we could fetch a user by their ID? We couldn’t just have a userPromise anymore, we’d now have to have a mapping from the user’s ID to the promise that fetches that user:

const userPromiseCache = new Map<string, Promise<User>>()
function fetchUser(id: string) {
const userPromise = userPromiseCache.get(id) ?? fetchUserImpl(id)
userPromiseCache.set(id, userPromise)
return userPromise
}
async function fetchUserImpl(id) {
const response = await fetch(`/api/user/${id}`)
const user = await response.json()
return user
}

This does get more complicated when you start thinking about the fact that the user data could change and now you have to worry about cache invalidation. But the point is that you need to cache your promises to avoid unnecessary re-fetching.

📜 Learn more about caching generally from Caching for Cash

Transitions

Whenever you trigger a state update that results in a suspending component, the closest suspense boundary will be found and its fallback prop will be rendered until the promise resolves. This can be a jarring experience for the user if the suspense boundary is far away from the component that triggered the state update or the loading state is very different from the resolved state.

To make the transition from loading to resolved state smoother, you can use the useTransition hook. This hook returns a tuple of two values: a boolean and a function. The boolean is true when the promise is still pending and false when the promise has resolved. The function is a callback that you can use to trigger the state update that will result in the promise being fetched.

function SomeComponent() {
const [someState, setSomeState] = useState(null)
const [isPending, startTransition] = useTransition()
const someResolvedValue = use(fetchSomeData(someState))
function handleChange(someValue) {
startTransition(() => {
setSomeState(someValue)
})
}
return (
<div>
{isPending ? <LoadingSpinner /> : <div>{someResolvedValue}</div>}
<button onClick={() => handleChange('some value')}>Change state</button>
</div>
)
}

The isPending variable there will be true while the suspending component is waiting for the promise to resolve and false when the promise has resolved and the component is ready to be rendered. This allows you to show a loading state on the existing UI rather than using the suspense boundary fallback UI. It often results in a much smoother user experience.

📜 Check out the useTransition documentation for more on this.

Promise Cache

🧝‍♂️ I’ve made a

couple of changes
. I moved the getShip call into the component itself so we can dynamically change which ship we’re looking at. I also added some buttons to allow us to do this from the UI.

But we’ve got a problem, and I’ve added a counter button to the UI so you can reproduce the problem. Every time you click it, we’re suspending again 😱 This is because we don’t have caching on our getShip function so every render triggers a new fetch call.

👨‍💼 Yeah, this is far from optimal for our application. Could you add a little cache for our getShip function so when it’s called with the same ship name it doesn’t request the ship again and suspend?

When you’re finished, you should be able to click the counter button as many times as you like without suspending. Additionally, because of the cache, once you’ve fetched the details for a ship, you should be able to switch back and forth and it should be instant.

If you want to reset the cache while you're working on this, simply refresh the page. We're not going to get into cache invalidation in this workshop.The slightly inaccurate warning talked about in the video is no longer present in the latest version of React. Instead the requests will just continue for an infinite loop! This step will fix the infinite requests.# useTransition

👨‍💼 Whenever we change the ship, our component suspends. But this is annoying to our users who are on reasonably fast internet connections because it means they see a loading spinner every time they change the ship when it really would be a better experience to have a more subtle pending state when the ship changes.

So what we want is to keep the old UI around while the new UI is being worked on (and display it in a way that makes it look like it’s loading). This is what useTransition is for! While React keeps the old UI around, it gives us a isPending state that we can use to show a loading spinner or something else while the new UI is loading:

const [isPending, startTransition] = useTransition()
function handleSomeEvent() {
startTransition(() => {
// This state change triggers a component to suspend, so we wrap it in a
// `startTransition` call to keep the old UI around until the new UI is ready.
setSomeState(newState)
})
}

Our designer just told us to use a 0.6 opacity setting while the ship is changing (for now). We can use the useTransition hook to accomplish this.

So wrap the state update in a transition and add opacity to the details so we can give our users a better experience!

Pending Flash

👨‍💼 When the user’s on a reasonably fast connection, getting the ship data doesn’t take very long and as a result the loading state doesn’t show up for all that long. This is great, except it results in a flash of the loading state while the ship takes ~50ms to load. It’s jarring for the user and it would be better if we could avoid it.

We thought about adding a CSS delay of 300ms on the opacity transition, but that would just mean the flash of loading state happens for people for whom the data loading takes 350ms or so. Just moving the problem 🤷‍♂️

What we really need is something that can give us the following experience:

  • If the loading takes less than 300ms, don’t show a loading spinner at all.
  • If the loading takes longer than 300ms, show a loading spinner for at least 350ms. Even if the loading is shorter than 300ms + 350ms.

This way, if the user has a reasonably fast connection, they won’t see a loading state at all, and if they have a medium fast connection, they’ll be guaranteed to not get a flash of loading state because the loading state will show up for at least 350ms.

Luckily, there’s a simple package that does exactly this: spin-delay. Here’s an example:

import { useSpinDelay } from 'spin-delay'
function MyComponent() {
const data = use(somePromise)
const [isPending, startTransition] = useTransition()
// options are optional, and default to these values
const showSpinner = useSpinDelay(isPending, { delay: 500, minDuration: 200 })
if (showSpinner) {
return <Spinner />
}
// ...
}

So let’s add this with a delay of 300 and a minDuration of 350 to the Ship component.

To test this experience out, you can update the getShip call with a second argument which is a delay that will ensure the request takes at least that amount of time (it’s not super precise, but it should give you an idea).

index.css

body {
margin: 0;
}
* {
box-sizing: border-box;
}
.app-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
}
.ship-buttons {
display: flex;
justify-content: space-between;
width: 300px;
padding-bottom: 10px;
}
.ship-buttons button {
border-radius: 2px;
padding: 2px 4px;
font-size: 0.75rem;
background-color: white;
color: black;
&:not(.active) {
box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.5);
}
&.active {
box-shadow: inset 0 0 4px 0 rgba(0, 0, 0, 0.5);
}
}
.app {
display: flex;
max-width: 1024px;
border: 1px solid #000;
border-start-end-radius: 0.5rem;
border-start-start-radius: 0.5rem;
border-end-start-radius: 50% 8%;
border-end-end-radius: 50% 8%;
overflow: hidden;
}
.search {
width: 150px;
max-height: 400px;
overflow: hidden;
display: flex;
flex-direction: column;
input {
width: 100%;
border: 0;
border-bottom: 1px solid #000;
padding: 8px;
line-height: 1.5;
border-top-left-radius: 0.5rem;
}
ul {
flex: 1;
list-style: none;
padding: 4px;
padding-bottom: 30px;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
li {
button {
display: flex;
align-items: center;
gap: 4px;
border: none;
background-color: transparent;
&:hover {
text-decoration: underline;
}
img {
width: 20px;
height: 20px;
object-fit: contain;
border-radius: 50%;
}
}
}
}
}
.details {
flex: 1;
height: 400px;
position: relative;
overflow: hidden;
}
.ship-info {
height: 100%;
width: 300px;
margin: auto;
overflow: auto;
background-color: #eee;
border-radius: 4px;
padding: 20px;
position: relative;
}
.ship-info.ship-loading {
opacity: 0.6;
}
.ship-info h2 {
font-weight: bold;
text-align: center;
margin-top: 0.3em;
}
.ship-info img {
width: 100%;
height: 100%;
aspect-ratio: 1;
object-fit: contain;
}
.ship-info .ship-info__img-wrapper {
margin-top: 20px;
width: 100%;
height: 200px;
}
.ship-info .ship-info__fetch-time {
position: absolute;
top: 6px;
right: 10px;
}
.app-error {
position: relative;
background-image: url('/img/broken-ship.webp');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
width: 400px;
height: 400px;
p {
position: absolute;
top: 30%;
left: 50%;
transform: translate(-50%, -50%);
background-color: white;
padding: 6px 12px;
border-radius: 1rem;
font-size: 1.5rem;
font-weight: bold;
width: 300px;
text-align: center;
}
}

index.tsx

import { Suspense, use, useState, useTransition } from 'react'
import * as ReactDOM from 'react-dom/client'
import { ErrorBoundary } from 'react-error-boundary'
import { useSpinDelay } from 'spin-delay'
import { getImageUrlForShip, getShip } from './utils.tsx'
function App() {
const [shipName, setShipName] = useState('Dreadnought')
const [isTransitionPending, startTransition] = useTransition()
const isPending = useSpinDelay(isTransitionPending, {
delay: 300,
minDuration: 350,
})
function handleShipSelection(newShipName: string) {
startTransition(() => {
setShipName(newShipName)
})
}
return (
<div className="app-wrapper">
<ShipButtons shipName={shipName} onShipSelect={handleShipSelection} />
<div className="app">
<div className="details" style={{ opacity: isPending ? 0.6 : 1 }}>
<ErrorBoundary fallback={<ShipError shipName={shipName} />}>
<Suspense fallback={<ShipFallback shipName={shipName} />}>
<ShipDetails shipName={shipName} />
</Suspense>
</ErrorBoundary>
</div>
</div>
</div>
)
}
function ShipButtons({
shipName,
onShipSelect,
}: {
shipName: string
onShipSelect: (shipName: string) => void
}) {
const ships = ['Dreadnought', 'Interceptor', 'Galaxy Cruiser']
return (
<div className="ship-buttons">
{ships.map((ship) => (
<button
key={ship}
onClick={() => onShipSelect(ship)}
className={shipName === ship ? 'active' : ''}
>
{ship}
</button>
))}
</div>
)
}
function ShipDetails({ shipName }: { shipName: string }) {
// 💯 Set different delays for different ships. Feel free to play around with the values.
const delay =
shipName === 'Interceptor' ? 200 : shipName === 'Galaxy Cruiser' ? 400 : 10
const ship = use(getShip(shipName, delay))
return (
<div className="ship-info">
<div className="ship-info__img-wrapper">
<img
src={getImageUrlForShip(ship.name, { size: 200 })}
alt={ship.name}
/>
</div>
<section>
<h2>
{ship.name}
<sup>
{ship.topSpeed} <small>lyh</small>
</sup>
</h2>
</section>
<section>
{ship.weapons.length ? (
<ul>
{ship.weapons.map((weapon) => (
<li key={weapon.name}>
<label>{weapon.name}</label>:{' '}
<span>
{weapon.damage} <small>({weapon.type})</small>
</span>
</li>
))}
</ul>
) : (
<p>NOTE: This ship is not equipped with any weapons.</p>
)}
</section>
<small className="ship-info__fetch-time">{ship.fetchedAt}</small>
</div>
)
}
function ShipFallback({ shipName }: { shipName: string }) {
return (
<div className="ship-info">
<div className="ship-info__img-wrapper">
<img src="/img/fallback-ship.png" alt={shipName} />
</div>
<section>
<h2>
{shipName}
<sup>
XX <small>lyh</small>
</sup>
</h2>
</section>
<section>
<ul>
{Array.from({ length: 3 }).map((_, i) => (
<li key={i}>
<label>loading</label>:{' '}
<span>
XX <small>(loading)</small>
</span>
</li>
))}
</ul>
</section>
</div>
)
}
function ShipError({ shipName }: { shipName: string }) {
return (
<div className="ship-info">
<div className="ship-info__img-wrapper">
<img src="/img/broken-ship.webp" alt="broken ship" />
</div>
<section>
<h2>There was an error</h2>
</section>
<section>There was an error loading "{shipName}"</section>
</div>
)
}
const rootEl = document.createElement('div')
document.body.append(rootEl)
ReactDOM.createRoot(rootEl).render(<App />)

utils.tsx

import { type Ship } from './api.server.ts'
export type { Ship }
const shipCache = new Map<string, Promise<Ship>>()
export function getShip(name: string, delay?: number) {
const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay)
shipCache.set(name, shipPromise)
return shipPromise
}
async function getShipImpl(name: string, delay?: number) {
const searchParams = new URLSearchParams({ name })
if (delay) searchParams.set('delay', String(delay))
const response = await fetch(`api/get-ship?${searchParams.toString()}`)
if (!response.ok) {
return Promise.reject(new Error(await response.text()))
}
const ship = await response.json()
return ship as Ship
}
export function getImageUrlForShip(
shipName: string,
{ size }: { size: number },
) {
return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}`
}

Tags

#React

Share

Previous Article
React Suspense 1: Data fetching

Table Of Contents

1
Dynamic Promises
2
Promise Cache
3
Pending Flash

Related Posts

React Testing 8: Testing custom hook
September 09, 2025
1 min
© 2025, All Rights Reserved.
Powered By

Quick Links

About Me

Legal Stuff

Social Media