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 userPromisefunction 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
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.
🧝♂️ I’ve made a
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.
👨💼 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!
👨💼 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:
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 valuesconst 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).
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;}}
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: stringonShipSelect: (shipName: string) => void}) {const ships = ['Dreadnought', 'Interceptor', 'Galaxy Cruiser']return (<div className="ship-buttons">{ships.map((ship) => (<buttonkey={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 : 10const ship = use(getShip(shipName, delay))return (<div className="ship-info"><div className="ship-info__img-wrapper"><imgsrc={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 />)
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}`}
Quick Links
Legal Stuff
Social Media