Most applications require some level of data fetching from a server. The code
required to perform this data fetching can be as simple as a fetch
request:
const response = await fetch('https://api.example.com/data')const data = await response.json()
That’s simple enough, but no matter how fast your server is, you need to think about what the user’s looking at while they wait. You don’t get to control the user’s network connection. For a similar reason you also need to think about what happens if the request fails. You can’t control the user’s connection relability either.
React has a nice way to manage both of these declaratively in components using
Suspense
and ErrorBoundary
.
The biggest trick is how to trigger these two things to happen when rendering
the UI. This is where the use
hook comes in:
function PhoneDetails() {const details = use(phoneDetailsPromise)// now you have the details}
What’s important for you to understand here is that the use
hook is passed a
promise. It’s not where you create a promise. You need to have triggered the
fetch
request somewhere else and then pass it along to the use
hook.
Otherwise every time your component renders you’ll trigger the fetch
request
again. However, there are ways around this which we’ll explore later on.
The real trick though is how the heck does use
turn a promise into a resolved
value without using await
!? We need to make sure the code does not continue if
the use
hook can’t return the resolved details
. So how does it manage to do
this? The answer is actually simpler than you might think.
Let me ask you some JavaScript trivia… How do you synchronously stop a
function from running to completion? You throw
something! So that’s exactly
what the use
hook does. It adds a .then
onto the promise so it can store the
resolved value, and then it throws the promise. When the promise resolves, React
will re-render your component and this time the use
hook will have the
resolved value to return!
This is kind of hilarious, but it works great. The implementation details of the
use
hook are a bit more complex and they definitely can change, but we’ll
implement a simplified version of it in this exercise.
To complete the declarative circle, when the promise is thrown, React will
“suspend” the component which means it will look up the tree of parent
components for a Suspense
component and render its boundary:
import { Suspense } from 'react'function App() {return (<Suspense fallback={<div>loading phone details</div>}><PhoneDetails /></Suspense>)}
This works similar to Error Boundaries in that the suspense boundary can handle any thrown promises in its children or grandchildren. Also they can be nested so you have a great amount of control over the loading state of your application.
If the promise rejects, then your ErrorBoundary
will be triggered and you can
render an error message to the user:
import { Suspense } from 'react'import { ErrorBoundary } from 'react-error-boundary'function App() {return (<ErrorBoundary fallback={<div>Oh no, something bad happened</div>}><Suspense fallback={<div>loading phone details</div>}><PhoneDetails /></Suspense></ErrorBoundary>)}
In this exercise, we’re going to build a simplified use
hook from scratch.
👨💼 Right now the app is “working.” This app displays information about space
ships from a fictional sci-fi universe. The app is using a fetch
request to
get the data in the
./shared/ship-api-utils.server.ts
.The problem is that while the fetch
request is ongoing, the user is just
staring at a blank white screen. Now we could for sure improve the HTML document
file a bit to have a loading state in HTML until the ship data shows up, but we
want to be able to manage transitions like this as the user navigates around as
well. Componentize all the things!
So for this first step, you’re going to need to remove the await
on the
getShip
call and then if the ship
data hasn’t loaded yet, you’ll throw the
shipPromise
. You can also wrap the <ShipDetails />
in a <Suspense>
boundary and render the <ShipFallback />
component so we have a nicer loading
state.
Give that a whirl!
👨💼 If the user has a bad network connection or something we want to handle that
error case gracefully. Please wrap the ShipDetails
in an ErrorBoundary
from
react-error-boundary
.
You can test this out by changing the shipName
to a ship that doesn’t exist.
We also have a good fallback for you to use that 🧝♂️ Kellie made called
ShipError
. Good luck!
👨💼 Let’s clean up things a little bit with what we’ve built so far by making the status of our promise a little more formal.
What we have now is:
But that’s not exactly clear.
So instead, let’s add a status
variable that can be
'pending' | 'fulfilled' | 'rejected'
(start it out with 'pending'
).
📜 To learn more about why this is important, read Make Impossible States Impossible and Stop using isLoading booleans
👨💼 With what you’ve built so far, we want you to make a reusable utility for
this use case. We want you to call it use
and it should take a promise and
return the Value
from the promise.
The only way we can do this is by tracking some values which we’ll monkey-patch
onto the promise
itself. So Kellie’s added a special type for you to use to
make TypeScript happier with the hackery we plan to perform for this simplified
version of use
.
🧝♂️ Here’s a good start for you:
type UsePromise<Value> = Promise<Value> & {status: 'pending' | 'fulfilled' | 'rejected'value: Valuereason: unknown}function use<Value>(promise: Promise<Value>): Value {const usePromise = promise as UsePromise<Value>// throw stuff, .then stuff, and return Value!}
That should get you a good start. When you’re done, you should be able to remove
a bunch of our code and replace it with a use
call. Good luck!
👨💼 Ok, let’s use React’s built-in use
function instead of our utility. Delete
all the code in there and replace it with
use
from React.
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 } from 'react'import * as ReactDOM from 'react-dom/client'import { ErrorBoundary } from 'react-error-boundary'import { getImageUrlForShip, getShip } from './utils.tsx'const shipName = 'Dreadnought'// 🚨 If you want to to test out the error state, change this to 'Dreadyacht'// const shipName = 'Dreadyacht'function App() {return (<div className="app-wrapper"><div className="app"><div className="details"><ErrorBoundary fallback={<ShipError />}><Suspense fallback={<ShipFallback />}><ShipDetails /></Suspense></ErrorBoundary></div></div></div>)}const shipPromise = getShip(shipName)function ShipDetails() {const ship = use(shipPromise)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() {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() {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 }export async function getShip(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