Contents
26 Chapter

Loading with Suspense and use()

Build streaming rendering with Suspense boundaries, plus `loading.tsx`, skeleton fallbacks, and the React 19 stable `use()` hook.

Chapter 25 showed how data fetching in a Server Component becomes simple. But the pages we built still have one issue: nothing shows until every piece of data arrives. Even when fast data and slow data live on the same page, everyone has to wait on the slowest piece.

This chapter covers the tools that fix that — <Suspense>, loading.tsx, and the use() hook that became officially stable in React 19. The streaming model from this chapter will come back in Chapter 31 (Web Vitals and performance) in connection with LCP / TTFB. use() is also one of the building blocks of Chapter 28 (React 19 features).

The problem — all-or-nothing #

Imagine the following page.

the problem scenario
export default async function Page() {
  const profile = await getProfile();   // 100ms (fast)
  const posts = await getPosts();       // 2000ms (slow)
  const stats = await getStats();       // 3000ms (slowest)

  return (
    <div>
      <Profile data={profile} />
      <Posts data={posts} />
      <Stats data={stats} />
    </div>
  );
}

This page shows a blank screen for 3 seconds. Even though profile is ready in 100ms, it cannot show on screen until stats is finished.

Parallelization (Promise.all) helps but does not solve the real problem. The page still has to wait until the slowest piece finishes.

The real fix is showing the ready parts first and filling in the rest as they become ready. That is what Suspense and streaming enable.

The basics of Suspense #

<Suspense> is a marker that tells React: “if the components inside are not ready yet, show the fallback instead, and swap in the real content when ready”.

Suspense basics
import { Suspense } from 'react';

<Suspense fallback={<p>Loading...</p>}>
  <SlowComponent />
</Suspense>

If SlowComponent takes time (data fetching, etc.), <p>Loading...</p> shows in its place, and once it is ready React swaps it automatically.

What makes this powerful on its own is that inside and outside a Suspense work independently. In the example above, the rest of the page can paint right away without waiting for SlowComponent.

Server Components + Suspense = streaming #

Suspense becomes really powerful with Server Components. Let’s rewrite the problem code.

split with Suspense
import { Suspense } from 'react';

export default async function Page() {
  const profile = await getProfile();  // 100ms is OK to wait

  return (
    <div>
      <Profile data={profile} />

      <Suspense fallback={<p>Loading posts...</p>}>
        <PostsSection />
      </Suspense>

      <Suspense fallback={<p>Loading stats...</p>}>
        <StatsSection />
      </Suspense>
    </div>
  );
}

async function PostsSection() {
  const posts = await getPosts();   // 2000ms
  return <Posts data={posts} />;
}

async function StatsSection() {
  const stats = await getStats();   // 3000ms
  return <Stats data={stats} />;
}

Here is what happens.

timeline
0ms      Server starts rendering the page
100ms    profile arrives → Profile + Suspense fallbacks ship to the client
         (user: profile is visible, the rest shows "Loading...")
2000ms   posts arrives → server streams Posts HTML to the client
         (user: Posts area swaps from fallback to real content automatically)
3000ms   stats arrives → Stats area swaps the same way

The page fills in progressively. Fast parts arrive fast; slow parts at their own pace. This is streaming.

From the user’s point of view the time until the white screen disappears drops from 3 seconds to 100ms. The actual fetch time does not change, but the perceived speed improves dramatically. As we will see in Chapter 31 (Web Vitals), LCP (Largest Contentful Paint) and TTFB (Time To First Byte) improve at the same time.

loading.tsx — a fallback for the whole page #

When you want to wrap the entire page as one Suspense unit, there is a shortcut: drop a loading.tsx file in the folder.

route structure
src/app/
├── layout.tsx
└── posts/
    ├── loading.tsx     ← automatically becomes a Suspense fallback
    └── page.tsx

src/app/posts/loading.tsx:

src/app/posts/loading.tsx
export default function Loading() {
  return (
    <div style={{ padding: '24px' }}>
      <p>Loading the posts page...</p>
    </div>
  );
}

Navigating to /posts shows this screen while page.tsx’s data prepares, and automatically swaps when ready. It has the same effect as wrapping the page with <Suspense>.

This is convenient when the entire page loads as a single unit. For finer-grained streaming (showing the fast parts first and only the slow parts as fallback), use <Suspense> directly inside the page.

Where to put Suspense boundaries #

To use Suspense effectively, you need a sense of where to draw the boundary. A decision guide.

  1. Put fast and slow data in different Suspense boundaries. Don’t let slow hide fast.
  2. Put things that should appear together in the same Suspense. For example, a post title and its author.
  3. Don’t slice too finely. A fallback on every tiny piece turns the screen into a patchwork of flickers.
  4. Cut along interaction units. A card or section the user perceives as one piece belongs in one Suspense. Big regions that fill the screen (sidebar, main, footer) typically each get their own Suspense.

Skeleton fallbacks #

A skeleton that resembles the real content is better UX than a “Loading…” text. Layout is reserved up front, so there is no jumping when the real content arrives.

src/app/posts/loading.tsx (skeleton)
function Skeleton({ width, height }: { width: string; height: string }) {
  return (
    <div style={{
      width,
      height,
      background: '#eee',
      borderRadius: '4px',
      animation: 'pulse 1.5s ease-in-out infinite',
    }} />
  );
}

export default function Loading() {
  return (
    <div style={{ padding: '24px' }}>
      <Skeleton width="60%" height="32px" />
      <div style={{ marginTop: '16px' }}>
        <Skeleton width="100%" height="60px" />
        <Skeleton width="100%" height="60px" />
        <Skeleton width="100%" height="60px" />
      </div>
    </div>
  );
}

(You also need a @keyframes pulse { ... } definition in globals.css)

Placing placeholders of the same size at the same positions as the real content makes the swap feel natural when the content arrives. The user gets a smooth transition without a white flicker.

The use() hook — resolve a Promise inside the component #

The hook use, newly stabilized in React 19, takes a Promise and returns its resolved value. It matters in two scenarios.

Scenario 1. Pass a Promise from a Server Component to a Client Component #

Suppose you want to start the fetch on the Server, but the component that consumes the result needs interaction and so must be a Client.

src/app/posts/page.tsx (Server)
import { Suspense } from 'react';
import PostList from './PostList';

type Post = { id: number; title: string };

export default function Page() {
  const postsPromise: Promise<Post[]> = fetch('https://api.example.com/posts')
    .then(r => r.json());

  return (
    <Suspense fallback={<p>Loading...</p>}>
      <PostList postsPromise={postsPromise} />
    </Suspense>
  );
}
src/app/posts/PostList.tsx (Client)
'use client';

import { use } from 'react';

type Post = { id: number; title: string };

export default function PostList({ postsPromise }: { postsPromise: Promise<Post[]> }) {
  const posts = use(postsPromise);

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

You create the Promise on the server, pass it to the client, and unwrap it on the client with use(promise). The key is not awaiting — you pass the Promise itself as a prop. The fetch begins on the server; the client waits for the Promise to resolve. When it does, React handles the Suspense fallback ↔ content swap automatically.

This is one example of the “props must be serializable” rule from Chapter 24 — Promise is a serializable value. If you remember the line “Promise (pairs with use() in Chapter 26)” from the Chapter 24 table, this is where that pays off.

The advantage of this pattern is that the server starts the fetch immediately. If you awaited first and then passed the result, the Suspense fallback would not appear during the await. By passing the Promise as-is, the moment the client tries to unwrap it inside a Suspense boundary, the fallback shows up immediately.

Scenario 2. Conditional Context usage #

useContext could only be called at the top of a function (the hook rules in Chapter 13 hooks typing), but use can be called inside a conditional.

conditional Context
'use client';

import { use } from 'react';
import { ThemeContext } from './ThemeContext';

function Card({ showTheme }: { showTheme: boolean }) {
  if (showTheme) {
    const theme = use(ThemeContext);  // OK inside an if
    return <div className={theme}>...</div>;
  }
  return <div>...</div>;
}

This is possible because use works via a mechanism different from regular hooks. It is not a pattern you use every day, but it is good to know when it comes in handy.

Note
use is a relatively new hook stabilized in React 19. It does not replace existing hooks like useContext or useState; think of it as an additional tool for handling Promises and Context more flexibly. For everyday data fetching, just awaiting inside a Server Component is the simplest path.

Try it — a progressively loading site #

Let’s build an example to feel the difference of progressive loading firsthand.

src/app/dashboard/page.tsx:

src/app/dashboard/page.tsx
import { Suspense } from 'react';

export default function DashboardPage() {
  return (
    <div style={{ padding: '24px' }}>
      <h1>Dashboard</h1>
      <p>Different sections of this page load at their own pace.</p>

      <section style={{ marginTop: '24px' }}>
        <h2>Profile (fast)</h2>
        <Suspense fallback={<Skeleton text="Loading profile..." />}>
          <Profile />
        </Suspense>
      </section>

      <section style={{ marginTop: '24px' }}>
        <h2>Notifications (medium)</h2>
        <Suspense fallback={<Skeleton text="Loading notifications..." />}>
          <Notifications />
        </Suspense>
      </section>

      <section style={{ marginTop: '24px' }}>
        <h2>Activity (slow)</h2>
        <Suspense fallback={<Skeleton text="Loading activity..." />}>
          <Activity />
        </Suspense>
      </section>
    </div>
  );
}

function Skeleton({ text }: { text: string }) {
  return (
    <div style={{ padding: '12px', background: '#f4f4f4', color: '#888', borderRadius: '4px' }}>
      {text}
    </div>
  );
}

function delay(ms: number) {
  return new Promise<void>(resolve => setTimeout(resolve, ms));
}

async function Profile() {
  await delay(500);
  return (
    <div style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '4px' }}>
      <strong>Alice</strong> · alice@example.com
    </div>
  );
}

async function Notifications() {
  await delay(2000);
  return (
    <ul style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '4px', listStyle: 'disc inside' }}>
      <li>3 new messages</li>
      <li>1 friend request</li>
    </ul>
  );
}

async function Activity() {
  await delay(4000);
  return (
    <ul style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '4px', listStyle: 'decimal inside' }}>
      <li>10 minutes ago  wrote a new post</li>
      <li>1 hour ago  left a comment</li>
      <li>3 hours ago  clicked Like</li>
    </ul>
  );
}

Open /dashboard.

  • After 0.5s the profile shows (the other two sections are still loading)
  • After 2s the notifications show
  • After 4s the activity shows

Each section appears at its own pace. A slow section does not make the others wait. This is what streaming actually looks like.

Looking at the page request in the browser’s network tab, you will also see the response not finishing at once but arriving progressively in chunks. The server is sending the ready parts as they become ready.

Common pitfalls #

1. Awaiting the entire page kills streaming #

🚫 streaming has no effect
export default async function Page() {
  const profile = await getProfile();
  const posts = await getPosts();    // waits for everything here
  const stats = await getStats();

  return (
    <>
      <Profile data={profile} />
      <Suspense fallback={<p>Loading...</p>}>
        <PostsSection data={posts} />     {/* already awaited so the fallback never shows */}
      </Suspense>
    </>
  );
}

If you await all the data at the top of the page function and pass it down as props, nothing reaches the client until the page function finishes, so Suspense does nothing. Push the await down into the child component.

2. Suspense applied at too small a unit #

🚫 too granular
{posts.map(post => (
  <Suspense key={post.id} fallback={<p>Loading...</p>}>
    <PostItem postId={post.id} />
  </Suspense>
))}

If every list item has its own Suspense boundary, items show up flickering one after another. Usually wrapping the whole list with one Suspense feels more natural.

3. Using use(Promise) inside a Server Component — usually just await #

use(Promise) is mainly meaningful in a Client Component that receives a Promise as a prop. In a Server Component, plain await is simpler and clearer.

Exercises #

  1. Observe streaming directly. Open the network tab in dev tools and inspect the /dashboard response from the example above. Confirm the Transfer-Encoding: chunked response header and that the response body accumulates over time. Then change the first line of the page function to something like const all = await Promise.all([profile(), notifications(), activity()]) to await everything up front, and compare how streaming disappears and the response itself slows down.
  2. Build the use() pattern yourself. From a Server Component, create a Promise<Post[]> and pass it as a prop to a Client Component child, which then unwraps it with use(postsPromise) and renders. Compare this against a version where the page await postsPromise first and passes the result, observing when the Suspense fallback appears differently.
  3. Designing Suspense boundaries. A page needs to display (a) a user header (50ms), (b) three recommendation cards (200ms each), and (c) an activity feed (1500ms). Decide which parts share a <Suspense> and which are separated, and write a paragraph explaining why, referencing the four guidelines in the chapter. There is no single right answer — being able to explain the trade-offs is enough.

In one line: <Suspense> is the boundary that swaps fallback ↔ content automatically, and combined with a Server Component’s async function it produces streaming that ships the ready parts first. A page-level fallback fits into a single loading.tsx file; finer splits use <Suspense> directly. Skeleton fallbacks remove the jumpy transition, and React 19’s use() hook lets a Client unwrap a Promise the Server created. Awaiting at the top of the page function kills streaming, so move await down into child components.

Next chapter #

So far we have only read data. In Chapter 27 Server Actions and forms, we take on the new paradigm for changing server data when a user submits a form or clicks a button. Server Actions let you call server functions without writing any API route, and React 19’s useActionState / useFormStatus together with this chapter’s Suspense + Server Actions wrap up Part 4 with a small mini project.

X