How to Build a Basic Suspense Boundary for Loading States
A Suspense boundary is a <Suspense> component that wraps child components and displays a fallback UI while their data is loading. Building one requires three steps: initiate a data fetch upfront (before rendering), pass the promise to a child component, and wrap the child in <Suspense> with a loading placeholder.
This article walks you through creating your first boundary, understanding how promises flow through it, and handling the coordination between data arrival and UI updates. By the end, you'll have a reusable pattern for any async data scenario.
Understanding Suspense Boundary Structure
A Suspense boundary has two parts: the boundary itself (the <Suspense> component) and the child components that throw promises. When a child throws an unresolved promise, React catches it at the boundary and displays the fallback. Once the promise resolves, React retries the child's render.
Here is the minimal structure:
import { Suspense } from 'react';
export function MyPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ChildComponent />
</Suspense>
);
}
function ChildComponent() {
// This child will throw a promise if data is not ready
return <div>Data loaded!</div>;
}
The boundary catches the promise, shows the fallback, and rerenders the child once the promise resolves. The fallback can be any React element: a spinner, skeleton, text, or custom component.
Creating a Promise-Throwing Component
For Suspense to work, a child must throw an unresolved promise. This is rarely done manually; instead, you use the use hook (React 19) to unwrap a promise. If the promise is pending, use throws it:
import { use, Suspense } from 'react';
// This initiates a fetch and returns a promise (not the data itself)
function fetchPostData(id) {
return fetch(`/api/posts/${id}`).then(r => r.json());
}
function PostView({ postPromise }) {
// use() unwraps the promise; if pending, throws it to Suspense
const post = use(postPromise);
return (
<article>
<h2>{post.title}</h2>
<p>{post.body}</p>
</article>
);
}
export function PostPage({ postId }) {
// Start the fetch immediately, before rendering PostView
const postPromise = fetchPostData(postId);
return (
<Suspense fallback={<div className="skeleton">Loading post...</div>}>
<PostView postPromise={postPromise} />
</Suspense>
);
}
When PostPage mounts, fetchPostData starts immediately. PostView receives the promise, calls use(postPromise), and if the fetch is not yet complete, use throws the promise. React catches it at the Suspense boundary and shows the fallback. Once the promise resolves, React retries PostView, use returns the data synchronously, and the post renders.
Building a Reusable Suspense Wrapper Component
Most apps create a wrapper that combines the boundary and async logic:
import { Suspense } from 'react';
function DefaultLoader() {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>Loading...</p>
</div>
);
}
export function SuspenseWrapper({
children,
fallback = <DefaultLoader />,
}) {
return <Suspense fallback={fallback}>{children}</Suspense>;
}
// Usage
export function HomePage() {
const dataPromise = fetchData();
return (
<SuspenseWrapper fallback={<div>Custom fallback</div>}>
<DataDisplay data={dataPromise} />
</SuspenseWrapper>
);
}
This wrapper reduces boilerplate: you pass children and an optional fallback, and it handles the Suspense mechanics. Reuse it across your app.
Key Patterns: Data Promise Timing
When you initiate a fetch and pass the promise to a component, timing is critical. The promise must be created before the component tree renders, so React can coordinate fallbacks:
// Correct: fetch upfront, pass promise to component
function CorrectExample({ userId }) {
const userPromise = fetchUser(userId); // Create now
return (
<Suspense fallback={<div>Loading user...</div>}>
<UserCard userPromise={userPromise} /> {/* Pass promise */}
</Suspense>
);
}
// Wrong: data source lives inside the component
function WrongExample({ userId }) {
return (
<Suspense fallback={<div>Loading user...</div>}>
<UserCard userId={userId} /> {/* Fetch happens inside; Suspense can't catch it */}
</Suspense>
);
}
In CorrectExample, the promise is already in motion before React renders the boundary. In WrongExample, the component might fetch data internally, and the boundary won't catch it properly.
Handling Multiple Promises in One Boundary
If a component needs multiple pieces of data, you can pass multiple promises:
import { use, Suspense } from 'react';
function UserDashboard({ userPromise, postsPromise, commentsPromise }) {
const user = use(userPromise);
const posts = use(postsPromise);
const comments = use(commentsPromise);
return (
<div>
<h1>{user.name}'s Dashboard</h1>
<section>
<h2>Posts ({posts.length})</h2>
{posts.map(p => <div key={p.id}>{p.title}</div>)}
</section>
<section>
<h2>Comments ({comments.length})</h2>
{comments.map(c => <div key={c.id}>{c.text}</div>)}
</section>
</div>
);
}
export function DashboardPage({ userId }) {
// Start all fetches upfront
const userPromise = fetchUser(userId);
const postsPromise = fetchUserPosts(userId);
const commentsPromise = fetchUserComments(userId);
return (
<Suspense fallback={<div>Loading dashboard...</div>}>
<UserDashboard
userPromise={userPromise}
postsPromise={postsPromise}
commentsPromise={commentsPromise}
/>
</Suspense>
);
}
The boundary shows the fallback until all three promises resolve. Once they do, the component rerenders with all data available synchronously.
Fallback UI Design Tips
A good fallback provides visual continuity and matches the shape of the final UI (skeleton loading). Here are common patterns:
// Simple spinner
<Suspense fallback={<div>⏳ Loading...</div>}>
// Skeleton that matches layout
function PostSkeleton() {
return (
<article style={{ padding: '1rem', background: '#f0f0f0' }}>
<div style={{ height: '2rem', background: '#ddd', marginBottom: '1rem' }} />
<div style={{ height: '1rem', background: '#ddd', marginBottom: '0.5rem' }} />
<div style={{ height: '1rem', background: '#ddd', width: '80%' }} />
</article>
);
}
<Suspense fallback={<PostSkeleton />}>
// Component-based loader
function LoadingSpinner() {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
<span style={{ animation: 'spin 1s linear infinite' }}>⌛</span>
</div>
);
}
<Suspense fallback={<LoadingSpinner />}>
Skeletons are most effective because they set layout expectations, reducing layout shift (CLS metric).
Common Pitfall: Boundaries Don't Catch All Suspense
A Suspense boundary only catches promises thrown by child components, not the component containing the boundary itself:
// The boundary doesn't catch its own promise
function BadBoundary() {
const data = use(somePromise); // Throws before Suspense is set up
return (
<Suspense fallback={<div>Loading...</div>}>
{/* content */}
</Suspense>
);
}
// Correct: pass promise to a child
function GoodBoundary() {
const data = somePromise;
return (
<Suspense fallback={<div>Loading...</div>}>
<Child dataPromise={data} /> {/* Child uses the promise */}
</Suspense>
);
}
Always wrap promise-throwing components inside the boundary, not the boundary itself.
Key Takeaways
- Build a boundary in three steps: initiate the fetch, pass the promise to a child, wrap the child in
<Suspense>. - The
usehook unwraps promises: When a promise is pending,usethrows it to the nearest Suspense boundary. - Timing matters: Create promises before rendering the component tree, not inside it.
- Multiple promises in one boundary: Pass all necessary promises to a single component; the boundary waits for all to resolve.
- Fallback UI should match layout: Use skeletons or placeholders that match the final UI shape to avoid layout shift.
Frequently Asked Questions
What's the difference between Suspense and a loading state?
A loading state (useState(loading)) tracks async progress inside one component. Suspense coordinates fallback UI across an entire subtree, eliminating boilerplate. Suspense also enables concurrent rendering (React pauses one subtree while rendering others), which loading states don't support.
Can I nest Suspense boundaries?
Yes. Nested boundaries show different fallbacks for different parts of the tree. Outer boundaries show only after inner boundaries resolve, allowing cascading loading:
<Suspense fallback={<div>Loading page...</div>}>
<PageHeader headerPromise={hPromise} />
<Suspense fallback={<div>Loading content...</div>}>
<PageContent contentPromise={cPromise} />
</Suspense>
</Suspense>
Do I need to manually manage the promise resolution?
No. Once you create a promise and pass it to use(), React handles the rest. use will throw it to Suspense if pending, and once resolved, it returns the data synchronously. No manual .then() or state updates needed.
Can Suspense work with fetch API directly?
Yes, but you must cache the promise. Raw fetch creates a new promise each time, so wrap it:
let cachedPromise = null;
function fetchUser(id) {
if (!cachedPromise) {
cachedPromise = fetch(`/api/users/${id}`).then(r => r.json());
}
return cachedPromise;
}
This ensures the same promise is returned across renders, preventing multiple requests.
What happens if the promise rejects?
The rejection propagates to the nearest Error Boundary (not Suspense). Wrap Suspense in an Error Boundary to catch rejections and show an error UI (see article 8).