cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

Suspense

Use React Suspense to show a fallback while part of the page waits: what can suspend, nested boundaries, transitions, lazy loading, and React 19 changes.

By the end, you can
  • 01
    Place Suspense boundariesWrap the parts of a page that wait, choose what loads together and what loads in steps, and write their fallbacks.
  • 02
    Know what makes a component suspendTell a cached promise read with use, lazy code, and framework data, which Suspense sees, from requests in effects, which it doesn't.
  • 03
    Keep the page steady while it loadsKeep shown content on screen with a transition, split code with lazy, and explain what React 19 changed: fallbacks first, then pre-warming.

A loading state you describe once

In Fetching data, every component that loaded something kept its own loading state: a null to start from, a check, and a Loading… line. Suspense moves that job out of the component. <Suspense> is a component that comes with React. You wrap part of the page in it and give it a fallback: what to show while anything inside is still waiting. Here the reviews come from a pretend server in the data.js tab, which answers after 1.5 seconds; nothing goes over the network. The result is live; press Run again to watch it load.

Reviews that wait inside a Suspense boundaryPop out in the code editor (opens in a new tab)JSX
import { Suspense, use } from "react";import { fetchData } from "./data.js"; function Reviews() {  const reviews = use(fetchData("/reviews/tea"));  return (    <ul>      {reviews.map((review) => <li key={review}>{review}</li>)}    </ul>  );} export default function ProductPage() {  return (    <>      <h2>Green tea</h2>      <Suspense fallback={<p>Loading reviews…</p>}>        <Reviews />      </Suspense>    </>  );}
ResultLoading…

Line 5 calls fetchData("/reviews/tea"), which returns a promise, and reads it with use, an API from React. While the promise hasn't resolved, use can't give back the reviews, so it stops Reviews from rendering. That's called suspending. React then looks for the nearest <Suspense> above it, the Suspense boundary on lines 17 to 19, and shows its fallback, Loading reviews…. The heading on line 16 is outside the boundary, so it shows at once.

When the promise resolves, React renders Reviews again. This time use returns the array, line 8 lists it, and the boundary swaps its fallback for its children, the components inside it. Notice what Reviews doesn't have: no null check and no loading message. It's written as if the data were already there, and the boundary owns the waiting.

Open the data.js tab. fetchData on lines 6 to 9 keeps every promise in a Map, so each key is loaded once, and every read of it gets the same promise back. That cache matters a lot, as the next section shows. The console says Load /reviews/tea once, from line 19.

Two browser windows. Left, while Reviews waits: the heading Green tea, and the fallback Loading reviews… where the reviews go. Right, when the reviews arrive: the heading, and the two reviews in place of the fallback.waitingGreen teaLoading reviews…fallbackwhile Reviews waitsreadyGreen teaFresh and light.Great with lunch.childrenwhen the reviews arrive
The heading is outside the boundary, so it shows at once. Inside, React shows the fallback while Reviews waits, and swaps in the children when the reviews arrive.
Real-life analogyA sign in a shop window

A shop changing one window's display doesn't close the shop. It hangs a sign in that window, sets the display up behind it, and takes the sign down when everything is in place. People never see a half-built display, and the rest of the shop works as usual.

In real life: The shop hangs a "New display coming" sign in one window
In React: <Suspense fallback={…}> wraps one part of the page
In real life: Staff set up the display behind the sign
In React: React waits for the promise Reviews reads
In real life: The door and the other windows stay open
In React: The heading outside the boundary shows at once
In real life: The sign comes down when the whole display is ready
In React: React swaps the fallback for the children
The main idea

A Suspense boundary shows its fallback while anything inside it is waiting, and its children once everything inside is ready. A component waits by suspending, for example by reading an unresolved promise with use. It reads its data as if it were there, and you decide where the loading states go by where you put the boundaries.

What can make a component wait

Suspense only reacts to components that suspend while they render. In React 19, a component suspends when it:

  • reads a promise with use before that promise has resolved, where the promise comes from a cache, as in data.js. The next lesson, The use API, covers use in full;
  • renders a component made with lazy whose code hasn't downloaded yet, which a later section covers;
  • reads data the way a Suspense-enabled framework, like Next.js or Relay, gives it to you. Inside, these keep a cache of their data and suspend for you.

A request in an effect isn't on the list. Here is the Fetching data way, inside a boundary:

A request in an effect, inside a boundaryPop out in the code editor (opens in a new tab)JSX
import { Suspense, useEffect, useState } from "react";import { fetchData } from "./data.js"; function Reviews() {  const [reviews, setReviews] = useState(null);  useEffect(() => {    fetchData("/reviews/tea").then((data) => setReviews(data));  }, []);  if (reviews === null) return <p>Reviews' own loading message</p>;  return (    <ul>      {reviews.map((review) => <li key={review}>{review}</li>)}    </ul>  );} export default function ProductPage() {  return (    <>      <h2>Green tea</h2>      <Suspense fallback={<p>Suspense fallback</p>}>        <Reviews />      </Suspense>    </>  );}
ResultLoading…

The result shows Reviews' own loading message from line 9, never Suspense fallback from line 21. Reviews rendered fine on its first try: it returned a paragraph. The effect on lines 6 to 8 runs after that, and React doesn't know it started a request. Suspense never hears about requests in effects or event handlers.

Why does the promise have to come from a cache? Here a component makes its promise while it renders, which is wrong on purpose:

A new promise on every render (wrong on purpose)Pop out in the code editor (opens in a new tab)JSX
import { Suspense, use } from "react"; // Wrong on purpose: every call makes a new promise.function loadReviews() {  console.log("Load /reviews/tea");  return new Promise((resolve) => {    setTimeout(() => resolve(["Fresh and light.", "Great with lunch."]), 1500);  });} function Reviews() {  const reviews = use(loadReviews());  return (    <ul>      {reviews.map((review) => <li key={review}>{review}</li>)}    </ul>  );} export default function ProductPage() {  return (    <>      <h2>Green tea</h2>      <Suspense fallback={<p>Loading reviews…</p>}>        <Reviews />      </Suspense>    </>  );}
ResultLoading…

Line 12 calls loadReviews during every render, and line 6 makes a new promise each time. After 1.5 seconds the first promise resolves, so React renders Reviews again. That render calls loadReviews again, which makes a new promise that hasn't resolved, so Reviews suspends again. The fallback never goes away, and the count beside Load /reviews/tea in the console keeps going up, every 1.5 seconds. (It starts at 2: once the fallback is on the screen, React renders Reviews once more to start its loading early, a pre-warm that a later section explains.) Nothing turns red; the page just never finishes. That's why data for use comes from a cache: data.js's Map, a framework, or a data library, which hand out the same promise for the same data.

How to read the table: each row is something a component does, whether the nearest boundary shows its fallback, and why.

What makes a boundary show its fallback
What the component doesFallback?Why
Reads a cached promise with use, before it resolvesYes.use suspends the component, and React shows the nearest fallback until the promise resolves.
Renders a lazy component whose code hasn't loadedYes.React waits for the file to download, showing the nearest fallback.
Reads data the Suspense way a framework gives youYes.Frameworks like Next.js keep a cache of their data and suspend for you.
Fetches in useEffect and keeps its own loading stateNo.The component rendered fine. Suspense never hears about the request.
Fetches in a click handlerNo.Nothing suspended while rendering; the handler only sets state later.
Reads a promise it made during this renderYes, and often for good.Each render makes a new promise, so the next render waits all over again. Promises for use must come from a cache.

One boundary shows its content together

A boundary is one unit: it shows all of its children, or its fallback. Put two waiting components in one boundary, and it waits for both. The product page below has its details and its reviews in their own files, the Details.jsx and Reviews.jsx tabs. Each reads its own data with use on its line 5: the product takes 0.6 seconds and the reviews 1.5.

Details and reviews in one boundaryPop out in the code editor (opens in a new tab)JSX
import { Suspense } from "react";import Details from "./Details.jsx";import Reviews from "./Reviews.jsx"; export default function ProductPage() {  return (    <Suspense fallback={<p>Loading the product…</p>}>      <Details productId="tea" />      <Reviews productId="tea" />    </Suspense>  );}
ResultLoading…

Lines 7 to 10 put both components in one boundary. At 0.6 seconds the details are ready, but React doesn't show them, because Reviews is still waiting. At 1.5 seconds both appear together. Both requests start straight away: the console shows Load /products/tea and Load /reviews/tea as the page opens, so there's no waterfall. The wait is the slower of the two, not their sum.

Use one boundary when the parts only make sense together, like a chart and its legend. Then the reader never sees one without the other.

Nested boundaries reveal the page in steps

When one part is quick and useful on its own, give the slow part a boundary of its own, inside the first one:

The reviews get their own boundaryPop out in the code editor (opens in a new tab)JSX
import { Suspense } from "react";import Details from "./Details.jsx";import Reviews from "./Reviews.jsx"; export default function ProductPage() {  return (    <Suspense fallback={<p>Loading the product…</p>}>      <Details productId="tea" />      <Suspense fallback={<p>Loading reviews…</p>}>        <Reviews productId="tea" />      </Suspense>    </Suspense>  );}
ResultLoading…

Lines 9 to 11 wrap Reviews in a second boundary, inside the outer one on lines 7 to 12. A boundary waits only for the components inside it that aren't inside a nested boundary, so the outer one now waits only for Details. Press Run again and watch three moments:

  1. At first, Loading the product…, the outer fallback.
  2. At 0.6 seconds, the heading Green tea: $3, with the inner fallback Loading reviews… under it.
  3. At 1.5 seconds, the reviews in place of the inner fallback.
Three moments of the product page. At 0 seconds: Loading the product…, the outer fallback. At 0.6 s: the heading Green tea: $3, and Loading reviews…, the inner fallback. At 1.5 s: the heading and both reviews.0 sLoading the product…0.6 sGreen tea: $3Loading reviews…1.5 sGreen tea: $3Fresh and light.Great with lunch.the outer fallbackthe inner fallbackeverything
The outer boundary waits only for Details, so the heading comes at 0.6 s. The inner boundary keeps its own fallback until the reviews arrive at 1.5 s.

Where you put boundaries is a design choice, not a technical one. Put them where a person would expect a separate loading state: the main content, a sidebar, a list of comments. Don't wrap every component: a page that pops in piece by piece is jumpy and hard to read. In the playground, load the same page with one boundary or two, and with slow or quick reviews. Each row is a moment the screen changed, timed from the load.

Playground: when does each part appear?
The product pagenested

Not loaded yet.

What the screen showed0 moments
    Try it yourself
    Nested boundaries, reviews take 1.5 s

    Choose a layout and how long the reviews take; the page loads as soon as you choose. Load again replays it.

    The product page runs here, with React's real Suspense and use on this page. The details take 0.6 seconds; you choose the reviews' time. Each row is a moment the screen changed, timed from the load, as a MutationObserver saw it. Every load starts with an empty cache.

    With One boundary, the heading waits for the reviews, until 1.5 seconds. With nested boundaries, it comes at 0.6 seconds and the reviews at 1.5. Then choose Reviews take 0.7 s with nested boundaries. The reviews are ready only 0.1 seconds after the details, but they appear at about 0.9 seconds. React reveals content at most once every 300 ms, counted from the last time a fallback appeared or was replaced, so the page doesn't flicker through steps that are only a moment apart.

    Boundaries matter on a server too. When a framework renders the page on a server, it can send the HTML outside the boundaries first, with the fallbacks, and then each boundary's content once it's ready. Since React 19.2, reveals that come close together are grouped there too. Streaming server rendering covers that.

    React 19 shows the fallback first

    What happens to the siblings of a component that suspends, like Reviews next to Details in one boundary? This changed in React 19, as its upgrade guide describes:

    • React 18 rendered every sibling first, and only then showed the fallback.
    • React 19 shows the fallback as soon as one component suspends. Then it renders the skipped siblings in a separate pass, to start whatever they load early. That pass is called pre-warming.

    You can watch it. The fallback below is a Spinner component that logs from an effect, and an effect only runs once its component is on the screen:

    When does the reviews request start?Pop out in the code editor (opens in a new tab)JSX
    import { Suspense, useEffect } from "react";import Details from "./Details.jsx";import Reviews from "./Reviews.jsx"; function Spinner() {  useEffect(() => {    console.log("Fallback on screen");  }, []);  return <p>Loading the product…</p>;} export default function ProductPage() {  return (    <Suspense fallback={<Spinner />}>      <Details productId="tea" />      <Reviews productId="tea" />    </Suspense>  );}
    ResultLoading…

    The console says Load /products/tea, then Fallback on screen (with a 2: StrictMode runs a new effect twice in development), and only then Load /reviews/tea. Details on line 15 suspended, so React skipped Reviews on line 16, put Spinner on the screen, and then rendered Reviews in a pre-warm, whose fetchData started the reviews request. In React 18, Load /reviews/tea came before Fallback on screen.

    Two rows of three steps. React 18: Details waits, Reviews asks for its data, then the fallback is shown. React 19: Details waits, the fallback is shown, then Reviews asks for its data in a pre-warm render.React 18Details waitsReviews asksfallback shownReact 19Details waitsfallback shownReviews asksthe pre-warm render
    The same boundary in both rows. React 18 rendered every sibling before it showed the fallback. React 19 shows the fallback as soon as one component waits, then renders the siblings to start their loading. That later render is the pre-warm.

    React's own code says so. When a component suspends, React leaves it and walks up to the nearest boundary; this excerpt from ReactFiberWorkLoop.js decides what happens to the siblings on the way:

    Skipping the siblings, from ReactFiberWorkLoop.js in React 19.3.1JavaScript
          // This is not a prerender. Skip the siblings during this render. A
          // separate prerender will be scheduled for later.
          skipSiblings = true;
          workInProgressRootDidSkipSuspendedSiblings = true;

    In plain words, line by line:

    1. This render isn't a pre-warm, so don't render the siblings now.
    2. A separate pre-warm render will be scheduled for later.
    3. skipSiblings = true makes React go straight up to the boundary, past Reviews.
    4. React notes that it skipped siblings, so that it schedules the pre-warm once the fallback is on the screen.

    The result: the reader sees a loading state sooner, and the siblings' loading still starts before they're shown. A pre-warm render is never shown and never runs effects; it only starts loading. That's one more reason to load data while rendering, through a cache, instead of in effects: a request in an effect can't start until its component is on the screen.

    Don't hide what's already on screen

    Once a boundary shows its content, a component inside it can still suspend later, for example when it needs new data. Here you can switch between two products:

    Switching products with a plain state updatePop out in the code editor (opens in a new tab)JSX
    import { Suspense, useState } from "react";import Details from "./Details.jsx";import Reviews from "./Reviews.jsx"; export default function ProductPage() {  const [productId, setProductId] = useState("tea");  return (    <>      <button onClick={() => setProductId("tea")}>Tea</button>      <button onClick={() => setProductId("cake")}>Cake</button>      <Suspense fallback={<p>Loading the product…</p>}>        <Details productId={productId} />        <Reviews productId={productId} />      </Suspense>    </>  );}
    ResultLoading…

    Line 6 keeps the picked product, and line 10 switches to cake. Wait for tea to load, then click Cake. Cake's data isn't in the cache yet, so Details suspends, and the boundary on lines 11 to 14 hides tea and shows Loading the product… again, for 1.2 seconds. React doesn't throw tea's content away: it hides it with display: none and keeps its state. Now click Tea: it shows at once, because its promise is in the cache and has resolved, so use returns its value straight away.

    That flash is unpleasant: you were reading about tea, and it vanished. A transition tells React that an update isn't urgent, so if it suspends, React keeps the old screen until the new one is ready. You met startTransition in Optimistic UI, and the Transitions lesson covers it in depth. Here is the same page with one:

    Switching products in a transitionPop out in the code editor (opens in a new tab)JSX
    import { Suspense, useState, useTransition } from "react";import Details from "./Details.jsx";import Reviews from "./Reviews.jsx"; export default function ProductPage() {  const [productId, setProductId] = useState("tea");  const [isPending, startTransition] = useTransition();   function pick(id) {    startTransition(() => {      setProductId(id);    });  }   return (    <>      <button onClick={() => pick("tea")}>Tea</button>      <button onClick={() => pick("cake")}>Cake</button>      <Suspense fallback={<p>Loading the product…</p>}>        <div style={{ opacity: isPending ? 0.5 : 1 }}>          <Details productId={productId} />          <Reviews productId={productId} />        </div>      </Suspense>    </>  );}
    ResultLoading…

    Line 7 calls useTransition, which gives two things: isPending, which is true while a transition waits, and startTransition. On lines 9 to 13, pick sets the product inside startTransition. Click Cake: tea stays on the screen, faded by line 20 while isPending is true, until cake's details and reviews are both ready, 1.2 seconds later. Then cake replaces it in one step.

    How to read the table: each row is one question, answered for a plain update and for a transition.

    A plain update or a transition, when the new content suspends
    QuestionA plain updateA transition
    What shows while cake loads?The fallback, in place of tea's details and reviews.Tea's details and reviews, dimmed with isPending.
    Where does tea's content go?React hides it with display: none and keeps its state.Nowhere: it stays on screen until cake is ready.
    When does cake appear?When its details and reviews are both ready.The same: when both are ready, all at once.
    When is it the right choice?When showing a loading state is what you want.When the old screen is more useful than a fallback, like switching tabs or pages.

    A transition only keeps content that is already showing. A boundary that appears for the first time still shows its fallback, even in a transition, because there's nothing old to keep. Routers in frameworks start every navigation as a transition for you.

    Load a component's code when it's needed

    Code can make a component wait too. A bundler, like Vite, puts everything an app imports into files the browser downloads before it shows anything. A big component that few people open, like a brewing guide, makes everyone wait for it. lazy from React, together with a dynamic import, moves it into a file of its own, which the browser downloads the first time the component renders:

    A brewing guide that loads on the first clickPop out in the code editor (opens in a new tab)JSX
    import { Suspense, lazy, useState } from "react"; const BrewingGuide = lazy(() => slowDownload(import("./BrewingGuide.jsx"))); // Pretends the file takes a second to download.function slowDownload(file) {  console.log("Download BrewingGuide.jsx");  return new Promise((resolve) => setTimeout(() => resolve(file), 1000));} export default function ProductPage() {  const [open, setOpen] = useState(false);  return (    <>      <h2>Green tea</h2>      <button onClick={() => setOpen(!open)}>{open ? "Hide" : "Show"} the brewing guide</button>      {open && (        <Suspense fallback={<p>Loading the guide…</p>}>          <BrewingGuide />        </Suspense>      )}    </>  );}
    ResultLoading…

    Line 3 calls lazy at the top level of the file, with a function that returns the import() promise. The file it imports must export the component as its default export, as the BrewingGuide.jsx tab does. The examples here load their files at once, so slowDownload on lines 6 to 9 adds a second, like a slow network. Line 19 renders BrewingGuide like any component, inside the boundary on lines 18 to 20.

    Click Show the brewing guide: Loading the guide… for a second, then the table. The console shows Download BrewingGuide.jsx and BrewingGuide.jsx runs (line 1 of the BrewingGuide.jsx tab) only after you click: before that, the file wasn't loaded at all. Click Hide, then Show again: the guide appears at once, because lazy keeps the component it loaded, so it doesn't suspend again.

    Two crates. The main bundle holds App.jsx, main.jsx, and react, and loads with the page. A separate file holds BrewingGuide.jsx, and an arrow labeled first click shows it loading later.App.jsxmain.jsxreactmain bundleloads with the pagefirst clickBrewingGuide.jsxits own fileloads when it's needed
    With lazy and import(), the bundler puts BrewingGuide.jsx in a file of its own. The page loads without it; the first time the guide renders, the browser downloads it, and Suspense shows the fallback meanwhile.

    Where you call lazy matters. This is wrong on purpose:

    lazy inside a component (wrong on purpose)JSX
    export default function ProductPage() {
      // Wrong: a new BrewingGuide component on every render.
      const BrewingGuide = lazy(() => import("./BrewingGuide.jsx"));
      // …
    }

    Line 3 runs on every render, so every render makes a new BrewingGuide component. React sees a different component each time, so it throws the old one away, with its state, and suspends again. Declare lazy components at the top level of a module, once.

    A product page with planned loading states

    Here is everything together: the details first, then the reviews in their own boundary, product switches in a transition, and a brewing guide whose code loads when it's opened.

    A product page with three boundariesPop out in the code editor (opens in a new tab)JSX
    import { Suspense, lazy, useState, useTransition } from "react";import Details from "./Details.jsx";import Reviews from "./Reviews.jsx"; const BrewingGuide = lazy(() => import("./BrewingGuide.jsx")); export default function ProductPage() {  const [productId, setProductId] = useState("tea");  const [guideOpen, setGuideOpen] = useState(false);  const [isPending, startTransition] = useTransition();   function pick(id) {    startTransition(() => setProductId(id));  }   return (    <>      <nav>        <button onClick={() => pick("tea")}>Tea</button>        <button onClick={() => pick("cake")}>Cake</button>        <button onClick={() => setGuideOpen(!guideOpen)}>Brewing guide</button>      </nav>      <Suspense fallback={<p>Loading the product…</p>}>        <main style={{ opacity: isPending ? 0.5 : 1 }}>          <Details productId={productId} />          <Suspense fallback={<p>Loading reviews…</p>}>            <Reviews productId={productId} />          </Suspense>        </main>      </Suspense>      {guideOpen && (        <Suspense fallback={<p>Loading the guide…</p>}>          <BrewingGuide />        </Suspense>      )}    </>  );}
    ResultLoading…

    Line 5 declares the lazy guide at the top level. Lines 12 to 14 switch products in a transition. The outer boundary on lines 23 to 30 waits for the details, the inner one on lines 26 to 28 for the reviews, and the guide has its own boundary on lines 32 to 34, so opening it never hides the product.

    Press Run again: Loading the product…, then the heading at 0.6 seconds with Loading reviews…, then the reviews at 1.5. Click Cake: tea stays, faded, and cake appears with its review 1.2 seconds later. In a transition React doesn't hide the reviews either, because they're already showing, so the switch waits for the slower part, the reviews. Click Brewing guide: its file loads quickly here, with no slowDownload, so you may only glimpse its fallback.

    Does it show a fallback?
    • Reviews reads use(fetchData("/reviews/tea")) before the reviews have arrived
    • Reviews loads in useEffect and keeps its own reviews === null check
    • A lazy component renders for the first time
    • The same lazy component opens a second time
    • setProductId("cake") while tea's details are on screen, before cake's data has loaded
    • startTransition(() => setProductId("cake")) while tea's details are on screen
    • A click handler awaits fetch and then calls setReviews
    • setProductId("tea") after tea's data is already in the cache
    Try it yourself
    0 of 8 correct

    Sort each situation: does the nearest Suspense boundary show its fallback, or not?

    Choose a category for every card. You can change an answer at any time; Reset clears them all.

    Common mix-ups

    • "Suspense shows its fallback while my effect fetches." No. The component rendered fine, so nothing suspended. Suspense only sees components that suspend while rendering: use with a cached promise, lazy, and Suspense-enabled frameworks.
    • "use can read any promise, even one made while rendering." No. A new promise on every render means every render waits again. The promise has to come from a cache that returns the same one.
    • "A boundary waits only for the component that suspended." No. It waits for everything inside it that isn't inside a nested boundary, and shows it all together.
    • "More boundaries always make a better page." No. Each boundary is a separate loading state. Too many make the page jump; put them where a person expects one.
    • "React 19 never renders the siblings of a component that suspends." No. It shows the fallback first, then renders them in a pre-warm, so their loading still starts early.
    • "A transition makes the data load faster." No. The data takes just as long. A transition only keeps what's already on the screen, instead of a fallback, until the new content is ready.
    • "lazy can go anywhere, even inside a component." No. Inside a component it makes a new component on every render, which loses its state and suspends again. Call it once, at the top level.
    • "Suspense shows the fallback when the data fails to load too." No. A promise that rejects makes use throw its error, which goes to the nearest error boundary. The Error boundaries lesson covers them.

    Practice exercises

    Each exercise uses the same pretend server: in its data.js tab, tea's details take 0.6 seconds and its reviews 1.5. Change the code with Edit & run, and press Run again to load from an empty cache.

    Exercise 1 · Warm-upPredict one boundary

    Details take 0.6 seconds and reviews 1.5. After how long does Green tea: $3 appear? Answer first, then press Edit & run and watch.

    import { Suspense } from "react";
    import Details from "./Details.jsx";
    import Reviews from "./Reviews.jsx";
    
    export default function ProductPage() {
      return (
        <Suspense fallback={<p>Loading the product…</p>}>
          <Details productId="tea" />
          <Reviews productId="tea" />
        </Suspense>
      );
    }

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 2 · Warm-upGive the reviews a boundary

      The result is blank for 1.5 seconds: Reviews suspends, and with no boundary, nothing shows, not even the heading. Add a boundary with the fallback Loading reviews… around the reviews. What shows under the heading while they load?

      import Reviews from "./Reviews.jsx"; export default function ProductPage() {  return (    <>      <h2>Green tea</h2>      <Reviews productId="tea" />    </>  );}
      ResultLoading…

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 3 · PracticeShow the heading first

        Change the page so the heading shows as soon as the details are ready, with Loading reviews… under it. After your change, what shows under the heading at 1 second?

        import { Suspense } from "react";import Details from "./Details.jsx";import Reviews from "./Reviews.jsx"; export default function ProductPage() {  return (    <Suspense fallback={<p>Loading the product…</p>}>      <Details productId="tea" />      <Reviews productId="tea" />    </Suspense>  );}
        ResultLoading…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 4 · PracticeLoad the guide only when it's opened

          The brewing guide's file is loaded with the page, though most people never open it. Load it with lazy the first time it's shown, with the fallback Loading the guide…. After your change, how many times does the console print BrewingGuide.jsx runs before you click?

          import { useState } from "react";import BrewingGuide from "./BrewingGuide.jsx"; export default function ProductPage() {  const [open, setOpen] = useState(false);  return (    <>      <h2>Green tea</h2>      <button onClick={() => setOpen(!open)}>{open ? "Hide" : "Show"} the brewing guide</button>      {open && <BrewingGuide />}    </>  );}
          ResultLoading…

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 5 · ChallengeFind the promise made on every render

            The top review shows after 1.5 seconds, but the console has an error from React: A component was suspended by an uncached promise. Which call in Reviews makes a new promise on every render? Fix it.

            import { Suspense, use } from "react";import { fetchData } from "./data.js"; function Reviews() {  const reviews = use(fetchData("/reviews/tea").then((list) => list.slice(0, 1)));  return <p>Top review: {reviews[0]}</p>;} export default function ProductPage() {  return (    <>      <h2>Green tea</h2>      <Suspense fallback={<p>Loading reviews…</p>}>        <Reviews />      </Suspense>    </>  );}
            ResultLoading…

            Answer, then press Check. Spacing and letter case don’t matter.

              Exercise 6 · ChallengeKeep the details while the reviews tab loads

              Wait for the details, then click Reviews: the details vanish behind Loading… for 1.5 seconds. Make the tab switch a transition, and fade the old tab while it waits. After your fix, what shows while the reviews load?

              import { Suspense, useState } from "react";import Details from "./Details.jsx";import Reviews from "./Reviews.jsx"; export default function ProductTabs() {  const [tab, setTab] = useState("details");  return (    <>      <button onClick={() => setTab("details")}>Details</button>      <button onClick={() => setTab("reviews")}>Reviews</button>      <Suspense fallback={<p>Loading…</p>}>        {tab === "details" ? <Details productId="tea" /> : <Reviews productId="tea" />}      </Suspense>    </>  );}
              ResultLoading…

              Answer, then press Check. Spacing and letter case don’t matter.

                Check your understanding

                For each question, find the boundary each component is in, then ask what that boundary is still waiting for.

                The Suspense quiz · 8 questionsScore: first tries count
                1. Question 1 of 8A component inside <Suspense fallback={<Spinner />}> suspends. What does React show?

                  Choose an answer to see the explanation.

                2. Question 2 of 8Which of these makes the nearest Suspense boundary show its fallback?

                  Choose an answer to see the explanation.

                3. Question 3 of 8Details take 600 ms and reviews 1500 ms. At 1 second, what does this show?

                  Read the code, then predictJSX
                  <Suspense fallback={<p>Loading the product…</p>}>
                    <Details productId="tea" />    {/* 600 ms */}
                    <Suspense fallback={<p>Loading reviews…</p>}>
                      <Reviews productId="tea" />  {/* 1500 ms */}
                    </Suspense>
                  </Suspense>

                  Choose an answer to see the explanation.

                4. Question 4 of 8With one boundary around both, when does the Green tea: $3 heading appear?

                  Read the code, then predictJSX
                  <Suspense fallback={<p>Loading the product…</p>}>
                    <Details productId="tea" />    {/* 600 ms */}
                    <Reviews productId="tea" />    {/* 1500 ms */}
                  </Suspense>

                  Choose an answer to see the explanation.

                5. Question 5 of 8In React 19, a component suspends and has siblings in the same boundary. What does React do?

                  Choose an answer to see the explanation.

                6. Question 6 of 8Why must lazy(() => import("./BrewingGuide.jsx")) sit at the top level of a module, not inside a component?

                  Choose an answer to see the explanation.

                7. Question 7 of 8Tea's details are on screen. You switch to cake inside startTransition. What do you see while cake loads?

                  Choose an answer to see the explanation.

                8. Question 8 of 8A component calls use(new Promise((resolve) => setTimeout(resolve, 1500))). What happens?

                  Choose an answer to see the explanation.

                Key takeaways

                • <Suspense fallback={…}> shows its fallback while anything inside it is waiting, and its children once all of them are ready.
                • Components suspend when they read an unresolved, cached promise with use, render a lazy component that hasn't loaded, or read framework data. Requests in effects don't suspend.
                • One boundary reveals its content together. Nested boundaries reveal the page in steps, each waiting only for its own part.
                • React 19 shows the fallback as soon as something suspends, then pre-warms the siblings so their loading starts early.
                • A plain update that suspends hides content already on screen. A transition keeps it until the new content is ready.
                • lazy and import() load a component's code the first time it renders. Declare it once, at the top level.

                Remember the one-liner.
                Components wait, boundaries show the fallback, and transitions keep what's already there.

                Coming next: The use API. It covers use in full: reading promises and context, calling it inside conditions, and where its promises should come from.

                CompleteFrontend Clear concepts. Working examples.