Modern React + Next.js #5 Loading with Suspense and use()
Last time we covered the simple pattern of fetching data in Server Components. But the pages we’ve built so far have a problem: the screen stays blank until all data has arrived. Even when fast and slow data live on the same page, everything waits for the slowest piece. This post covers the tools that solve this — Suspense, loading.js, and the use() hook.
The problem — All-or-Nothing #
Imagine the following page.
export default async function Page() {
const profile = await getProfile(); // 100 ms (fast)
const posts = await getPosts(); // 2000 ms (slow)
const stats = await getStats(); // 3000 ms (slowest)
return (
<div>
<Profile data={profile} />
<Posts data={posts} />
<Stats data={stats} />
</div>
);
}This page shows a blank screen for 3 seconds. The profile is ready in 100 ms, but it can’t appear until stats finishes.
Parallelizing (Promise.all) helps but doesn’t solve the fundamental issue. The whole screen still has to wait for the slowest piece.
The real fix is showing parts that are ready and filling in the rest as they arrive. That’s what Suspense and streaming make possible.
Suspense in a nutshell #
<Suspense> is a marker that tells React: “If the component inside isn’t ready, show this fallback in its place; swap it out when the component is ready.”
import { Suspense } from 'react';
<Suspense fallback={<p>Loading...</p>}>
<SlowComponent />
</Suspense>If SlowComponent is slow because of data fetching, <p>Loading...</p> shows in the meantime, and once it’s ready React swaps it in automatically.
What makes this powerful on its own is that the inside and outside of a Suspense boundary are independent. In the example above, the rest of the page can render immediately without waiting for SlowComponent.
Server Components + Suspense = Streaming #
Combining Server Components with Suspense becomes truly powerful. Let’s rewrite the problem code.
import { Suspense } from 'react';
export default async function Page() {
const profile = await getProfile(); // OK to wait 100 ms
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(); // 2000 ms
return <Posts data={posts} />;
}
async function StatsSection() {
const stats = await getStats(); // 3000 ms
return <Stats data={stats} />;
}What now happens:
0 ms Server starts rendering the page
100 ms profile arrives → Profile + Suspense fallbacks ship to the client
(User: profile is visible, the rest say "Loading...")
2000 ms posts arrives → server pushes the Posts HTML to the client
(User: the Posts area swaps from fallback to real content)
3000 ms stats arrives → Stats area swaps in the same wayThe page is filled in progressively. Fast parts appear fast; slow parts come in at their own pace. This is streaming.
For the user, the time the screen is blank shrinks from 3 seconds to 100 ms. The actual data arrival time hasn’t changed, but the perceived speed improves dramatically.
loading.js — page-wide fallback #
When you want to wrap an entire page with a single Suspense, there’s a shorthand. Just put a loading.js file in the folder.
src/app/
├── layout.js
└── posts/
├── loading.js ← becomes a Suspense fallback automatically
└── page.jssrc/app/posts/loading.js:
export default function Loading() {
return (
<div style={{ padding: '24px' }}>
<p>Loading posts page...</p>
</div>
);
}When you navigate to /posts, this screen shows while page.js’s data is loading and gets replaced automatically when ready. Same effect as wrapping the page in <Suspense>.
This is convenient when the whole page loads as one unit. For finer-grained streaming (showing fast parts immediately and showing a fallback only for the slow parts), use <Suspense> directly inside the page.
Where to draw Suspense boundaries #
Using Suspense effectively means having a feel for where to draw the boundaries. Some guidelines:
- Put fast and slow data in different Suspense boundaries — so the slow side doesn’t hide the fast side
- Put parts that should appear together in the same Suspense — e.g., a post’s title and author
- Don’t slice too finely — putting fallbacks on every tiny piece turns the screen into a flickering patchwork
Skeleton fallbacks #
A skeleton that looks like the real content is better UX than plain “Loading…” text. With the layout already in place, there’s no jumping when the real content arrives.
function Skeleton({ width, height }) {
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>
);
}(Note that you’ll need a @keyframes pulse { ... } definition in globals.css.)
Placing same-sized placeholders where the content will go gives a smooth swap when content arrives. The user sees a smooth transition without a flash of white.
The use() hook — unwrap a Promise inside a component #
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 data fetching on the server, but the component that uses the result needs interaction and must be Client.
import PostList from './PostList';
export default function Page() {
const postsPromise = fetch('https://api.example.com/posts')
.then(r => r.json());
return (
<Suspense fallback={<p>Loading...</p>}>
<PostList postsPromise={postsPromise} />
</Suspense>
);
}'use client';
import { use } from 'react';
export default function PostList({ postsPromise }) {
const posts = use(postsPromise);
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}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 on the server but passing the Promise itself as a prop. The fetch starts on the server; the client waits for the Promise to arrive, and once it does React handles the Suspense fallback ↔ content swap automatically.
The advantage is that the fetch starts immediately on the server. If you await before passing the prop, the Suspense fallback never shows during that await. By passing the Promise as-is, the moment the client tries to unwrap it inside the Suspense boundary the fallback appears.
Scenario 2. Use a Context conditionally #
useContext can only be called at the top level of a function (the rules of hooks), but use can be called inside a conditional.
'use client';
import { use } from 'react';
import { ThemeContext } from './ThemeContext';
function Card({ showTheme }) {
if (showTheme) {
const theme = use(ThemeContext); // OK inside a conditional
return <div className={theme}>...</div>;
}
return <div>...</div>;
}This is possible because use works through a different mechanism than ordinary hooks. It’s not something you’ll reach for daily, but it’s good to know it exists.
use is a relatively new hook stabilized in React 19. It doesn’t replace existing hooks like useContext or useState — think of it as an additional tool for handling Promises and Context more flexibly. For ordinary data fetching, just awaiting in a Server Component is the simplest approach.Hands-on — a progressively loading site #
Let’s build an example that lets you feel the difference progressive loading makes.
src/app/dashboard/page.js:
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div style={{ padding: '24px' }}>
<h1>Dashboard</h1>
<p>Each section of this page loads at its 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 }) {
return (
<div style={{ padding: '12px', background: '#f4f4f4', color: '#888', borderRadius: '4px' }}>
{text}
</div>
);
}
async function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function Profile() {
await delay(500);
return (
<div style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '4px' }}>
<strong>Alex</strong> , alex@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 — commented</li>
<li>3 hours ago — liked something</li>
</ul>
);
}Visit /dashboard.
- After 0.5 seconds, the profile shows (the other two are still loading)
- After 2 seconds, notifications show
- After 4 seconds, activity shows
Each section appears at its own pace. A slow area doesn’t hold up the others. This is what streaming looks like in practice.
If you watch the page request in your browser’s network tab, you’ll see the response doesn’t finish in one go but arrives progressively in chunks — the server is sending the parts it has ready.
Common pitfalls #
1. Wrapping the whole page in awaits and killing the streaming #
export default async function Page() {
const profile = await getProfile();
const posts = await getPosts(); // everything waits here
const stats = await getStats();
return (
<>
<Profile data={profile} />
<Suspense fallback={<p>Loading...</p>}>
<PostsSection data={posts} /> {/* the await is already done — the fallback never shows */}
</Suspense>
</>
);
}If the page function awaits all the data and then passes it to children as props, nothing reaches the client until the page function is done, so Suspense has no effect. Move the awaits into the child components.
2. Suspense applied at too small a granularity #
{posts.map(post => (
<Suspense key={post.id} fallback={<p>Loading...</p>}>
<PostItem postId={post.id} />
</Suspense>
))}Putting a separate Suspense boundary on each item in a list makes them flicker in one by one. It’s usually more natural to wrap the whole list in a single Suspense.
3. Using use(Promise) inside a Server Component — usually just await instead
#
use(Promise) is mainly meaningful in Client Components that received a Promise as a prop. In Server Components, plain await is simpler and clearer.
Wrap-up #
This post covered the tools for progressive loading.
- Suspense — a boundary that auto-swaps fallback ↔ content
- Server Components + Suspense = streaming (send what’s ready first)
loading.js— page-level automatic Suspense- Skeleton fallbacks — smooth transitions without jumping
- The
use()hook — a new tool for handling Promises and Context more flexibly
Up to now we’ve only read data. What about when a user submits a form or clicks a button to modify server data? In the next and final post, “Modern React + Next.js #6 Server Actions and Forms,” we’ll cover Next.js’s newest and most powerful tool — Server Actions — and finish the series with a small mini-project that combines everything we’ve learned.