Read a promise while rendering with use, and show a loading message with Suspense. Learn why the promise must be cached, where boundaries go, Transitions and errors.
In Part 13 we loaded data in an Effect. It worked, but it took a lot of code. We needed state for loading, error and data, an ignore flag, and a cleanup. Part 13 made a promise of its own: React 19 has a new way to wait for data.
That way has two pieces. use reads the value of a promise while your component renders. <Suspense> shows something else, like “Loading…”, until the value is there. In Part 23 you used use to read context. Here it reads a promise.
This part covers both pieces and the one rule you must not break. Then it shows where to put the loading messages. It also shows how to keep old content on the page, and what happens when the data never comes.
Try this first
As in Part 13, the examples here can’t use the real internet. Each one has a small fake server. It is a function that waits a bit, as a real server would, and then answers.
Read this code. Don’t press Run yet.
import { Suspense, use } from 'react'
type User = { id: number; name: string }
// A fake server. It waits 1000 ms, then answers with a user.
function fetchUser(id: number): Promise<User> {
console.log('Server: someone asked for user', id)
return new Promise(resolve => {
setTimeout(() => resolve({ id, name: 'Ana' }), 1000)
})
}
// Ask the server once, when this code first runs.
const userPromise = fetchUser(1)
function Profile() {
const user = use(userPromise)
return <p>Hello, {user.name}!</p>
}
export default function App() {
return (
<div>
<h1>My page</h1>
<Suspense fallback={<p>Loading...</p>}>
<Profile />
</Suspense>
</div>
)
}
Server: someone asked for user 1
Make two guesses. What does the page show first? And how many times will the Console say that someone asked the server?
Now press Run.
The page shows “My page” and “Loading…” first. After one second, “Loading…” changes to “Hello, Ana!”. The Console shows one line. In Part 13, Strict Mode made the Effect ask the server twice. Here the server was asked once.
Now look at what is missing. There is no useState, no useEffect, no ignore flag, and no if for loading. Profile reads the user as if it were already there.
What use and Suspense do
Let’s name the pieces.
fetchUser(1) gives back a promise, as in Part 13. While the value has not come yet, the promise is pending.
use(userPromise) reads the value of the promise. Say the promise is already fulfilled, and React has seen it. Then use gives back the value, and the component goes on. If the promise is still pending, the component can’t go on, so React stops rendering it. React’s docs call this suspending. To suspend means to stop for a while. The docs say the component “suspends while the Promise is pending”.
<Suspense> is a component that comes with React. It draws a boundary, a line around one part of the page. Everything inside that line waits together. Its fallback prop is what to show while something inside is waiting. A fallback is what React shows while it waits. Here it is <p>Loading...</p>.
Watch one render, step by step. The figure has a second scenario too, with two boundaries. We’ll come back to it later in this part.
Suspense and use, render by render. Pick a scenario, then press play, or step through it.
Here is the first scenario in words.
- React renders
App, and thenProfile. Profilecallsuse(userPromise). The promise is still pending, soProfilesuspends.- React looks up the tree for the closest
<Suspense>aboveProfile. It shows that boundary’s fallback, “Loading…”. The<h1>is outside the boundary, so it shows as normal. - One second later, the promise is fulfilled. React renders
Profileagain, from the first line. - This time
usegives back the user, and the page shows “Hello, Ana!”.
Step 4 matters most. React doesn’t pause Profile and go on later from the same line. It throws that render away and calls Profile again, from the start. The big rule in the next section comes from this.
One more thing about use. Its name starts with use, but React’s docs say: “Despite its name, use is not a Hook.” Unlike a Hook, it can go inside an if or a loop. Part 23 called it after an early return. It must still be called inside a component or a Hook.
An everyday example
You order food at a counter. You get a ticket with a number on it. That ticket is the promise. You sit down, and a card on your table says “Your food is coming”. That card is the fallback. When the food is ready, the card goes away and the food is on the table.
The exact version
In the story, you sit at the same table the whole time. React is different. If the waiting component was never on the page, React throws it away. When the value comes, React makes it again from the start, so nothing inside it is kept. If it was already on the page, React hides it and keeps it. Keeping the old content shows this.
Suspense also waits only for things React knows about. It doesn’t notice data that you load in an Effect or an event handler. React’s docs say so on the <Suspense> page. It works with use reading a promise. It also works with code loaded by lazy, which Part 34 covers. The docs list a few more cases.
The big rule: make the promise outside the render
In “Try this first”, userPromise is made at the top of the file, outside every component. What if you make it inside Profile?
import { Suspense, use } from 'react'
type User = { id: number; name: string }
// A fake server. It waits 1000 ms, then answers with a user.
function fetchUser(id: number): Promise<User> {
console.log('Server: someone asked for user', id)
return new Promise(resolve => {
setTimeout(() => resolve({ id, name: 'Ana' }), 1000)
})
}
function Profile() {
// Wrong: this makes a new promise on every render.
const user = use(fetchUser(1))
return <p>Hello, {user.name}!</p>
}
export default function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<Profile />
</Suspense>
)
}
Run it, and watch the Console. The page shows “Loading…” and never shows Ana. Every second, two more lines say “Server: someone asked for user 1”. It never stops. We ran it in a browser for 6 seconds: two requests every second. It was the same with Strict Mode off, so this is not Strict Mode. React tries the render a second time on its own.
Here is why. Profile suspends, and React throws that render away. When the promise is fulfilled, React renders Profile again, from the start. That new render calls fetchUser(1) again. It gets a new promise, which is pending. So Profile suspends again, and the loop goes on.
React’s docs say it like this: “React doesn’t preserve state for renders that suspended before mounting”. Mounting means being added to the page for the first time. So after each suspend, React starts that component again from nothing, and a promise made in it is made again.
So the docs give use one rule: “The Promise must be cached”. Every render must get the same promise object, not a new one. Keeping something so you can use it again is caching, as Part 13 said.
In development, React 19.3 has a warning for this mistake. It reads:
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.
Uncached means not kept in a cache. But React doesn’t always print this warning. In our tests, it printed the warning when the fake server answered at once, in 0 ms. That page even showed Ana in the end, after about 350 ms and a few extra requests. In a browser, we saw 3. So a fast server can hide this bug. With the 1000 ms server above, React printed nothing at all. So don’t wait for the warning. Follow the rule.
useState and useMemo don’t help here
You might try to keep the promise in Profile with useMemo or useState. That doesn’t work either. Part 21 gave one case where React throws the useMemo cache away. That is when a component has to wait the first time it is added. The docs quote above says the same for all state.
We tried both inside Profile: useMemo(() => fetchUser(1), []) and useState(() => fetchUser(1)). Each one kept asking the server and never showed Ana. In a browser, that was four requests every second with Strict Mode on, and two with it off.
So keep the promise somewhere that doesn’t wait. There are three common places.
Place 1: at the top of the file
That is “Try this first”. The code outside your components runs once, when the file loads. Strict Mode doesn’t run it twice, so the server was asked once.
It’s fine for a small demo. But it can’t use props or state, like a user id. And it starts loading even if the page never shows Profile.
Place 2: an event handler, then state
React’s docs say: “Ideally, Promises are created before rendering”. The first place they name is “an event handler”. The promise is then passed to the component that calls use.
Here the click makes the promise. App keeps it in state and passes it down as a prop:
import { Suspense, use, useState } from 'react'
type User = { id: number; name: string }
// A fake server. It waits 1000 ms, then answers with a user.
function fetchUser(id: number): Promise<User> {
console.log('Server: someone asked for user', id)
return new Promise(resolve => {
setTimeout(() => resolve({ id, name: 'Ana' }), 1000)
})
}
function Profile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise)
return <p>Hello, {user.name}!</p>
}
export default function App() {
const [userPromise, setUserPromise] = useState<Promise<User> | null>(null)
return (
<div>
<button onClick={() => setUserPromise(fetchUser(1))}>Load user</button>
{userPromise && (
<Suspense fallback={<p>Loading...</p>}>
<Profile userPromise={userPromise} />
</Suspense>
)}
</div>
)
}
Server: someone asked for user 1
Run it and click Load user. You see “Loading…”, then “Hello, Ana!”. The Console shows one line for one click.
This works because App never suspends. It sits above the Suspense boundary, so React keeps its state. On every render, Profile gets the same promise from App.
Click Load user again. The click makes a new promise, so “Loading…” comes back, and the server is asked a second time. A new promise means new data. That is what you want here.
Could you start the request without a click, with useState(() => fetchUser(1)) in App? It works, but in development it asks twice. As Part 4 showed, Strict Mode calls that function twice. We counted 2 requests with Strict Mode on, and 1 with it off.
Place 3: a small cache, by id
Often the data depends on an id from state or props. Then you can keep one promise per id in a cache. Here, a JavaScript Map is the cache. A Map stores values, each one under a key. cache.get(id) reads the value under that key, and cache.set(id, promise) saves one.
import { Suspense, use, useState } from 'react'
type User = { id: number; name: string }
const names = ['Ana', 'Ben', 'Cara']
// A fake server. User 2 is slow: 2000 ms. The others take 500 ms.
function fetchUser(id: number): Promise<User> {
console.log('Server: someone asked for user', id)
const wait = id === 2 ? 2000 : 500
return new Promise(resolve => {
setTimeout(() => resolve({ id, name: names[id - 1] }), wait)
})
}
// One promise per user id. Ask the server only the first time.
const cache = new Map<number, Promise<User>>()
function getUser(id: number): Promise<User> {
let promise = cache.get(id)
if (!promise) {
promise = fetchUser(id)
cache.set(id, promise)
}
return promise
}
function Profile({ id }: { id: number }) {
const user = use(getUser(id))
return <p>Hello, {user.name}!</p>
}
export default function App() {
const [userId, setUserId] = useState(1)
return (
<div>
<button onClick={() => setUserId(userId - 1)} disabled={userId === 1}>Previous</button>
<button onClick={() => setUserId(userId + 1)} disabled={userId === 3}>Next</button>
<h2>User {userId}</h2>
<Suspense fallback={<p>Loading...</p>}>
<Profile id={userId} />
</Suspense>
</div>
)
}
Server: someone asked for user 1
Server: someone asked for user 2
Run it. Wait for Ana, then click Next. User 2 is slow, so you see “Loading…” for two seconds, and then “Hello, Ben!”. Now click Previous. Ana comes back at once, with no “Loading…”. The Console has one line for each user, and no more.
getUser(1) asks the server only the first time. Every later call gives back the same promise, so Profile can call getUser(id) while it renders. That is safe now, because the promise comes from the cache, not from the render.
Why was there no “Loading…” for Ana the second time? Her promise was already fulfilled. When use gets a promise that React has seen fulfilled, it gives back the value at once, without suspending. React’s docs say the same, in the part on caching promises.
This cache is small on purpose. It keeps every answer forever. If Ana changes her name on the server, this page never knows. A real cache also needs a way to forget old answers and ask again.
What real apps use
The docs say: “The way you cache Promises depends on the framework you use with Suspense. Frameworks typically provide built-in caching mechanisms.” A framework is a bigger tool built on top of React, as Part 13 said. The <Suspense> page adds that such a framework “maintains a cache of Promises”. It then calls use on them.
So in a real app, you usually don’t write the cache yourself. You use your framework’s way to load data, or a data library that works with Suspense. Without one, a small cache like getUser is what the docs show.
Where to put the boundaries
A page often waits for more than one thing. Here a profile has a short bio, which is fast, and a list of posts, which is slow. First, one boundary around both:
import { Suspense, use } from 'react'
// A fake server: it answers with `value` after `ms` milliseconds.
function wait<T>(ms: number, value: T): Promise<T> {
return new Promise(resolve => setTimeout(() => resolve(value), ms))
}
const bioPromise = wait(500, 'Ana likes trains.')
const postsPromise = wait(2000, ['My first post', 'A trip to the sea'])
function Bio() {
const bio = use(bioPromise)
return <p>{bio}</p>
}
function Posts() {
const posts = use(postsPromise)
return (
<ul>
{posts.map(post => <li key={post}>{post}</li>)}
</ul>
)
}
export default function App() {
return (
<Suspense fallback={<p>Loading the page...</p>}>
<Bio />
<Posts />
</Suspense>
)
}
wait<T> is a fake server that can answer with any value. The <T> means “some type”. It lets TypeScript know that wait(500, 'Ana likes trains.') gives a promise of a string.
Run it. “Loading the page…” stays for two seconds. Then the bio and the posts appear together. The bio was ready after half a second, but it waited for the posts. React’s docs say: “By default, the whole tree inside Suspense is treated as a single unit.”
Now give the posts their own boundary, inside the first one:
import { Suspense, use } from 'react'
// A fake server: it answers with `value` after `ms` milliseconds.
function wait<T>(ms: number, value: T): Promise<T> {
return new Promise(resolve => setTimeout(() => resolve(value), ms))
}
const bioPromise = wait(500, 'Ana likes trains.')
const postsPromise = wait(2000, ['My first post', 'A trip to the sea'])
function Bio() {
const bio = use(bioPromise)
return <p>{bio}</p>
}
function Posts() {
const posts = use(postsPromise)
return (
<ul>
{posts.map(post => <li key={post}>{post}</li>)}
</ul>
)
}
export default function App() {
return (
<Suspense fallback={<p>Loading the page...</p>}>
<Bio />
<Suspense fallback={<p>Loading posts...</p>}>
<Posts />
</Suspense>
</Suspense>
)
}
Run it. This time the page changes twice. First “Loading the page…”. After half a second, the bio appears, with “Loading posts…” under it. After two seconds, the posts appear. We measured those three steps at about 0, 500 and 2000 ms.
The second scenario in the figure above shows the same thing. When Posts suspends, React uses the closest boundary above it. That is now the inner one, so only the posts wait. When Bio suspends, the closest boundary is the outer one.
So you choose with boundaries:
- One boundary shows its parts together, all at once.
- Nested boundaries show the outside first, then fill in the inside as it comes.
There is a limit. Inside content can’t show before the content around it. We changed the times around: the bio took 2000 ms and the posts 500 ms. The page showed “Loading the page…” for two seconds, and then everything at once. The posts were ready early, but their boundary is inside the outer one, so they waited.
Don’t put a boundary around every small part, either. React’s docs say: “Don’t put a Suspense boundary around every component.” Ten parts that appear one at a time are hard to read. Choose the loading steps you want the user to see, and put the boundaries there.
The exact version
React doesn’t show new content the moment it is ready. The docs say: “React reveals suspended content at most once every 300ms”. React counts the 300 ms from the last time it showed new content. We tried it with posts that took 600 ms instead of 2000. The bio appeared at 500 ms, but the posts didn’t appear at 600 ms. They appeared at about 800 ms, 300 ms after the bio. Parts that are ready close together show up together, so the page jumps less.
Keeping the old content with a Transition
Go back to the Place 3 example. When you clicked Next, Ana went away and “Loading…” took her place. That is what a boundary does when something inside it suspends again.
React doesn’t remove Ana from the page. It hides her with the CSS display: none, and shows the fallback next to her. We looked at the HTML right after the click. It was <p style="display: none !important;">Hello, Ana!</p><p>Loading...</p>.
Often it’s better to keep showing Ana until Ben is ready. You can do that with a Transition. Part 29 showed useTransition. It gives you isPending and startTransition.
import { Suspense, use, useState, useTransition } from 'react'
type User = { id: number; name: string }
const names = ['Ana', 'Ben', 'Cara']
// A fake server. User 2 is slow: 2000 ms. The others take 500 ms.
function fetchUser(id: number): Promise<User> {
console.log('Server: someone asked for user', id)
const wait = id === 2 ? 2000 : 500
return new Promise(resolve => {
setTimeout(() => resolve({ id, name: names[id - 1] }), wait)
})
}
// One promise per user id. Ask the server only the first time.
const cache = new Map<number, Promise<User>>()
function getUser(id: number): Promise<User> {
let promise = cache.get(id)
if (!promise) {
promise = fetchUser(id)
cache.set(id, promise)
}
return promise
}
function Profile({ id }: { id: number }) {
const user = use(getUser(id))
return <p>Hello, {user.name}!</p>
}
export default function App() {
const [userId, setUserId] = useState(1)
const [isPending, startTransition] = useTransition()
function goTo(id: number) {
startTransition(() => {
setUserId(id)
})
}
return (
<div>
<button onClick={() => goTo(userId - 1)} disabled={userId === 1}>Previous</button>
<button onClick={() => goTo(userId + 1)} disabled={userId === 3}>Next</button>
<h2>User {userId}</h2>
{isPending && <p>Getting the next user...</p>}
<Suspense fallback={<p>Loading...</p>}>
<Profile id={userId} />
</Suspense>
</div>
)
}
Run it. Wait for Ana, then click Next. This time “Hello, Ana!” stays. “Getting the next user…” appears above her. Two seconds later, the page switches to user 2 and Ben, in one step. “Loading…” never shows after the first load.
React’s docs explain what the Transition tells React: “it’s better to keep showing the previous page instead of hiding any already revealed content.”
Notice two things.
- The heading still says “User 1” while you wait. The whole update is in the Transition, so all of it waits, not only the part that suspended.
userIdis still 1 while the Transition waits. So if you click Next twice quickly, both clicks ask for user 2. We tried it. The page went to user 2, not 3. Thanks to the cache, the server was still asked for user 2 only once.
A Transition keeps content that is already on the page. A boundary that is new, never shown before, still shows its fallback at once. React’s docs say this too.
Sometimes the new content is really different, like another user’s profile. Then you may want the fallback after all. React’s docs say to give the boundary a key: <Suspense key={userId} fallback={...}>. A new key tells React this is new content, so it shows the fallback. We tried it in the example above. After Next, the page showed “User 2” and “Loading…”, even inside the Transition.
Compared with Part 13
Let’s put the two ways side by side. Part 13’s main example and the Place 3 example both move between users, and both have a slow user 2.
Part 13: useEffect |
This part: use and <Suspense> |
|
|---|---|---|
| Loading message | your own state and an if |
the fallback prop |
| State you write for the request | { status, user, message } |
none |
Cleanup and ignore flag |
needed | not needed |
| Requests on the first load, Strict Mode on | 2 | 1 |
| Click Next twice quickly | the ignore flag stops Ben |
shows Cara, and Cara stays |
| A failed request | your catch and an error state |
the nearest error boundary |
We checked the race in the Place 3 example. Ana showed, and then we clicked Next twice quickly. Cara appeared half a second later. Ben’s slow answer came about 1.4 seconds after that, and the page still showed Cara.
There is no race here, and no flag is needed. The page always reads the promise for the current id. Ben’s late answer goes into the cache under id 2. Nothing on the page is reading id 2 any more, so nothing changes.
The use way has costs, too. You need a cache, and a simple one never forgets. And loading starts when Profile renders, unless you start it earlier. The docs say that loading during render “delays network requests and can create waterfalls”. Part 13 explained waterfalls: one request starts only after another one ends.
When the promise fails
A promise can be rejected, too. What happens then?
import { Suspense, use } from 'react'
// A fake server that is down. After 1000 ms, it fails.
function fetchName(): Promise<string> {
return new Promise((_resolve, reject) => {
setTimeout(() => reject(new Error('The server is down')), 1000)
})
}
const namePromise = fetchName()
function Profile() {
const name = use(namePromise)
return <p>Hello, {name}!</p>
}
export default function App() {
return (
<div>
<h1>My page</h1>
<Suspense fallback={<p>Loading...</p>}>
<Profile />
</Suspense>
</div>
)
}
_resolve starts with _ to say that we don’t use it. This server never answers with a name.
Run it. “Loading…” shows for one second. Then the whole page goes empty, even “My page”. In its place, the playground shows the error: The server is down.
When the promise is rejected, use throws its error, as if Profile had a throw in it. React’s docs say that then “the fallback of the nearest Error Boundary will be displayed”. An error boundary is a component that catches errors from the components inside it. It shows a message instead. This app has none. So React removed the whole app from the page.
Part 32 builds error boundaries. With one around <Suspense>, only that part of the page shows an error message, and the rest stays.
Common mistakes
Making the promise inside the component
import { use, useMemo } from 'react'
type User = { id: number; name: string }
declare function fetchUser(id: number): Promise<User>
function Profile() {
const userPromise = useMemo(() => fetchUser(1), [])
const user = use(userPromise)
return <p>Hello, {user.name}!</p>
}
This looks careful, but it is the loop from the big rule. The same goes for use(getUser(1).then(user => user.name)). The cache gives the same promise, but .then makes a new one on every render. React’s docs list this case too. Profile suspends before it is ever on the page, so React keeps none of its useMemo or state. The fix: make the promise somewhere else. Use one of the three places above.
declare function tells TypeScript that fetchUser exists somewhere else, as in Part 13. It keeps the example short.
No Suspense boundary above
Here is “Try this first” without the <Suspense>:
import { use } from 'react'
type User = { id: number; name: string }
// A fake server. It waits 1000 ms, then answers with a user.
function fetchUser(id: number): Promise<User> {
return new Promise(resolve => {
setTimeout(() => resolve({ id, name: 'Ana' }), 1000)
})
}
const userPromise = fetchUser(1)
function Profile() {
const user = use(userPromise)
return <p>Hello, {user.name}!</p>
}
export default function App() {
return (
<div>
<h1>My page</h1>
<Profile />
</div>
)
}
Run it. For one second, the page is empty. There is no “Loading…”, and not even “My page”. Then everything appears at once. React found no boundary above Profile, so the whole app waited.
The fix: put a <Suspense> with a fallback around the part that waits. Keep it close to that part, so the rest of the page can show.
One big boundary around the whole app
A single <Suspense> at the top of the app works, but it hides everything. The menu, the heading and the buttons all wait for the data that comes last. And when something deep inside suspends again, the whole page turns into the fallback.
The fix: add boundaries closer to the parts that wait, as in Where to put the boundaries. For updates to content that is already showing, use a Transition.
Wrapping use in try and catch
import { Suspense, use } from 'react'
type User = { id: number; name: string }
// A fake server. It waits 1000 ms, then answers with a user.
function fetchUser(id: number): Promise<User> {
return new Promise(resolve => {
setTimeout(() => resolve({ id, name: 'Ana' }), 1000)
})
}
const userPromise = fetchUser(1)
function Profile() {
try {
const user = use(userPromise)
return <p>Hello, {user.name}!</p>
} catch {
return <p>Something went wrong.</p>
}
}
export default function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<Profile />
</Suspense>
)
}
Run it. The page shows “Something went wrong.” at once, and it stays. But nothing went wrong. The server was only slow.
To suspend, use throws something special inside React. The catch caught it, so React never saw it. React prints this in the Console:
`use` was called from inside a try/catch block. This is not allowed and can lead to unexpected behavior. To handle errors triggered by `use`, wrap your component in a error boundary.
React’s docs say use “cannot be called inside a try-catch block”. The fix: take away the try and catch. Handle failed requests with an error boundary (Part 32).
Forgetting that a request can fail
Every request can fail. With no error boundary, one rejected promise takes the whole app off the page, as in When the promise fails. Plan the error message when you plan the loading message.
Practice
Press Edit on any example above and try these.
- In “Try this first”, change
1000to3000. How long does “Loading…” stay? How many lines does the Console show? - In the Place 2 example, delete the button. In
App, writeconst [userPromise] = useState(() => fetchUser(1))instead, and show<Profile>without theuserPromise &&check. Run it. How many lines does the Console show? Why? - In the nested boundaries example, change the times around: give the bio
2000and the posts500. What does the page show, and when? - In the Place 3 example, wait for Ana. Click Next, wait for Ben, click Previous, then Next again. How many lines does the Console show? When did you see “Loading…”?
Answers
- “Loading…” stays for about 3 seconds, then “Hello, Ana!” appears. The Console still shows 1 line. The promise is made once, outside every component, so Strict Mode doesn’t repeat it.
- 2 lines. In development, Strict Mode calls the
useStatefunction twice, sofetchUser(1)runs twice. The page still shows “Loading…” and then “Hello, Ana!”. With Strict Mode off, or in production, it would be 1 line. The full app is below. - “Loading the page…” for two seconds. Then the bio and the posts appear together. The posts were ready after half a second. But they are inside the outer boundary, so they waited for the bio.
- 2 lines: one for user 1 and one for user 2. “Loading…” showed at the start, for Ana, and once for Ben, the first time. The second time, Ben’s promise was in the cache and already fulfilled. So he showed at once.
Answer 2, the app:
import { Suspense, use, useState } from 'react'
type User = { id: number; name: string }
// A fake server. It waits 1000 ms, then answers with a user.
function fetchUser(id: number): Promise<User> {
console.log('Server: someone asked for user', id)
return new Promise(resolve => {
setTimeout(() => resolve({ id, name: 'Ana' }), 1000)
})
}
function Profile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise)
return <p>Hello, {user.name}!</p>
}
export default function App() {
const [userPromise] = useState(() => fetchUser(1))
return (
<div>
<Suspense fallback={<p>Loading...</p>}>
<Profile userPromise={userPromise} />
</Suspense>
</div>
)
}
Server: someone asked for user 1
Server: someone asked for user 1
Interview questions
Try to answer each one out loud before you open the answer.
What does <Suspense> do?
It draws a boundary around part of the page. Say a component inside it suspends, because it is waiting for data or code. Then React shows the boundary’s fallback in place of that part. When everything inside is ready, React shows the real content. The closest boundary above the waiting component is the one that shows its fallback.
A strong answer adds that Suspense doesn’t notice data loaded in an Effect or an event handler. It works with use reading a promise, with lazy, and with frameworks built for it.
What does use(promise) do? Is use a Hook?
It reads the value of a promise during render. If the promise is fulfilled, it gives back the value. If it is pending, the component suspends, and the nearest <Suspense> shows its fallback. If it is rejected, use throws the error to the nearest error boundary.
React’s docs say it is not a Hook, even though its name starts with use. It can be called inside an if or a loop. It must still be called inside a component or a Hook, and not inside try and catch. A strong answer mentions that use can also read context, as Part 23 showed. It also knows that a rejected promise stays rejected. A cache like getUser gives back the same rejected promise every time. So to try again, you need a new promise: remove the old one from the cache first.
Why must the promise passed to use be cached?
React doesn’t keep anything from a render that suspended before the component was ever on the page. When the promise is fulfilled, React renders the component again from the start. If the component makes the promise itself, that new render makes a new promise, which is pending again. So it can suspend forever, and it keeps asking the server. In our test, the fallback never went away.
Make the promise outside the render. Put it at the top of a file, in an event handler, or in a cache keyed by id. A strong answer adds that useMemo and useState inside the waiting component don’t help. React throws them away too. It also knows React’s warning about an “uncached promise”, and that React doesn’t always print it.
Two parts of a page load data. How do you choose where to put the Suspense boundaries?
Ask how the page should appear. One boundary around both shows them together, when the slower one is ready. A boundary around each, or nested boundaries, lets the faster part show first. Inner content can never show before the content around it.
A strong answer says not to wrap every component. Many parts that appear one at a time are hard to follow. React’s docs say to match the loading steps the user should see. It may also mention that React shows new content at most once every 300 ms. So parts that finish close together appear together.
A list is on the page. The user picks a new one, and the whole list turns into a spinner. How do you keep the old list?
Make the update a Transition, with startTransition or useTransition. During a Transition, React keeps showing content that is already on the page, instead of its fallback. It switches when the new content is ready. isPending from useTransition lets you show a small “loading” note meanwhile.
A strong answer adds three details. The whole update waits, so other state set in the same Transition also stays old until then. A boundary that is new still shows its fallback, because there is nothing old to keep. Some content is really different, like another user’s profile. For that, a key on the boundary makes it show the fallback again.
What happens when the promise passed to use is rejected?
use throws the error. React looks for the nearest error boundary above the component and shows its fallback. If there is none, React removes the whole app from the page. In our test, the page went empty and the error was reported as not caught.
You can’t wrap use in try and catch. React throws something special to suspend, and a catch would catch that too. In our test, the catch showed its error message at once, while the data was still loading.
How does loading data with use and Suspense compare with loading it in useEffect?
With an Effect, you write state for loading, error and data, and an if to choose what to show. You also write a cleanup with an ignore flag to stop races. With use and Suspense, the component reads the data as if it were there. The boundary shows the loading message, and an error boundary shows errors.
With a cache keyed by id, there’s no race: the page always reads the promise for the current id. In our test, a late answer for user 2 didn’t replace user 3.
A strong answer names the costs. You need a cache, and a simple one never forgets. And loading that starts during render can cause waterfalls. React’s docs suggest starting requests earlier, like in an event handler. Or use a framework with its own cache.
Sources
- use, react.dev:
use(promise)suspends while the promise is pending, the closest Suspense fallback, rejected promises and the nearest error boundary, “Despite its name, use is not a Hook”, the caching rule, “React doesn’t preserve state for renders that suspended before mounting”, where promises should be created, the cacheMap, “reads the already-resolved value synchronously without suspending”, frameworks’ caching, that.thenon a cached promise makes a new promise on every render, and thatuse“cannot be called inside a try-catch block”. - <Suspense>, react.dev: the
fallbackprop, that Suspense “does not detect when data is fetched inside an Effect or event handler”, what activates a boundary, revealing content together and nested, “Don’t put a Suspense boundary around every component”, the 300 ms reveal rule, Transitions that keep revealed content, new boundaries that still show fallbacks, and akeyto reset a boundary for different content. - useTransition, react.dev:
isPendingandstartTransition. - React v19, React blog: the new
useAPI, and the text of the “uncached promise” warning. - StrictMode, react.dev: which functions run twice in development.
- The page text over time, the request counts, the reveal times (about 0, 500, 800 and 2000 ms), the hidden content’s
display: none, the race result, and the warning and error texts come from running React 19.3.0 for this post, in jsdom and in headless Chromium. The requests per second come from the Chromium runs.