Back to CourseIntermediate4 min read
React /

Suspense for Data Fetching

Let React handle loading states declaratively with Suspense boundaries, instead of an isLoading flag in every component.

Bishal Biswas

Bishal Biswas

Self Learner

Published on: May 13, 2026
Last updated: September 5, 2026
Intermediate Topic

The idea

Instead of every component tracking its own loading flag, you wrap part of the tree and say “show this while anything inside is not ready”.

import { Suspense } from 'react';
function Page() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile />
    </Suspense>
  );
}

If UserProfile suspends, the spinner shows. When it is ready, the real content replaces it. UserProfile itself contains no loading logic at all.

What suspends

A component suspends when it reads a resource that is not ready. In practice that means:

  • use(promise), covered in the use topic.
  • lazy() for code splitting.
  • A framework’s data loading, such as Next.js.
  • A library with Suspense support, such as TanStack Query with useSuspenseQuery.

A plain fetch in useEffect does not suspend. This is the most common misunderstanding. Suspense is not a data fetching mechanism, it is a way to coordinate loading states for resources that support it.

The comparison

function UserProfile({ userId }) {
  const { data, isLoading, error } = useFetch(`/api/users/${userId}`);
  if (isLoading) return <Spinner />;
  if (error) return <p>Error</p>;
  return <h2>{data.name}</h2>;
}
function UserProfile({ userPromise }) {
  const user = use(userPromise);
  return <h2>{user.name}</h2>;
}
function Page({ userPromise }) {
  return (
    <ErrorBoundary fallback={<p>Error</p>}>
      <Suspense fallback={<Spinner />}>
        <UserProfile userPromise={userPromise} />
      </Suspense>
    </ErrorBoundary>
  );
}

The component shrinks to the success case. Loading moves to Suspense, failure moves to the error boundary. Each concern is handled in one place rather than repeated everywhere.

Placing boundaries

Where you put the boundary decides what the user sees.

One boundary for everything

<Suspense fallback={<PageSkeleton />}>
  <Header />
  <Sidebar />
  <MainContent />
</Suspense>

Nothing appears until everything is ready. Simple, but the slowest item holds up the whole page.

A boundary per section

<div>
  <Suspense fallback={<HeaderSkeleton />}>
    <Header />
  </Suspense>
  <Suspense fallback={<SidebarSkeleton />}>
    <Sidebar />
  </Suspense>
  <Suspense fallback={<ContentSkeleton />}>
    <MainContent />
  </Suspense>
</div>

Each part appears as its data arrives. Better perceived performance, though too many boundaries makes the page feel like it is assembling itself piece by piece.

A good rule: put boundaries around things that are genuinely independent, and use a skeleton shaped like the real content so the layout does not jump.

Avoiding waterfalls

This is the trap.

function Page() {
  return (
    <Suspense fallback={<Spinner />}>
      <User />
    </Suspense>
  );
}
function User() {
  const user = use(fetchUser());
  return (
    <Suspense fallback={<Spinner />}>
      <Posts userId={user.id} />
    </Suspense>
  );
}

Posts cannot start until User finishes, because it needs user.id. Two requests in sequence.

When the requests are independent, start them together:

function Page() {
  const userPromise = fetchUser();
  const postsPromise = fetchPosts();
  return (
    <div>
      <Suspense fallback={<Spinner />}>
        <User userPromise={userPromise} />
      </Suspense>
      <Suspense fallback={<Spinner />}>
        <Posts postsPromise={postsPromise} />
      </Suspense>
    </div>
  );
}

Both start immediately. Each section renders when its own data lands.

The principle is start fetching as early as possible, and read as late as possible.

Suspense and transitions

By default, a state update that causes suspension replaces the content with the fallback. That is jarring when the user already had something on screen.

function TabPanel() {
  const [tab, setTab] = useState('home');
  const [isPending, startTransition] = useTransition();
  function selectTab(next) {
    startTransition(() => setTab(next));
  }
  return (
    <div>
      <button onClick={() => selectTab('posts')}>Posts</button>
      <Suspense fallback={<Spinner />}>
        <TabContent tab={tab} style={{ opacity: isPending ? 0.6 : 1 }} />
      </Suspense>
    </div>
  );
}

Inside a transition, React keeps the old content visible and does not show the fallback again. You get isPending instead, so you can dim the stale content.

This is the difference between a page that flickers to a spinner on every tab change and one that feels smooth.

Server rendering and streaming

With a framework, Suspense boundaries become streaming boundaries. The server sends the shell immediately, then streams each section’s HTML as its data resolves.

export default function Page() {
  return (
    <div>
      <Header />
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />
      </Suspense>
    </div>
  );
}

The header is readable straight away while comments are still being fetched on the server. There is more on this in the streaming SSR topic.

Common mistakes

Expecting useEffect fetching to suspend. It does not. Use a library or a framework.

Creating the promise inside the suspending component. That causes an infinite loop, as covered in the use topic.

A fallback that changes the layout. If the skeleton is a different size from the content, the page jumps. Match the shape.

Forgetting the error boundary. Suspense handles loading only. A rejected promise needs an error boundary, which is the next topic.

Quick recap

  • Suspense shows a fallback while a component’s resource is not ready.
  • Only resources that support it suspend, and plain effect fetching does not.
  • Components become just the success case.
  • More boundaries means faster perceived loading and a busier page.
  • Start independent requests together to avoid waterfalls.
  • Inside a transition React keeps old content instead of showing the fallback.
  • Match the skeleton shape to prevent layout jumps.
  • Always pair Suspense with an error boundary.
script.js

import { Suspense, use, useState, useTransition } from 'react';
// The component becomes ONLY the success case. Loading is handled by
// Suspense above it, failure by an error boundary above that.
function UserCard({ userPromise }) {
  const user = use(userPromise);
  return <h2>{user.name}</h2>;
}
function PostList({ postsPromise }) {
  const posts = use(postsPromise);
  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}
// AVOID WATERFALLS: start independent requests together, at the top,
// and read them as late as possible.
export default function ProfilePage({ userId }) {
  const userPromise = fetchUser(userId);      // both start now
  const postsPromise = fetchPosts(userId);
  return (
    <ErrorBoundary fallback={<p>Something went wrong.</p>}>
      <div>
        {/* One boundary per independent section, so each part appears
            as its own data arrives */}
        <Suspense fallback={<CardSkeleton />}>
          <UserCard userPromise={userPromise} />
        </Suspense>
        <Suspense fallback={<ListSkeleton />}>
          <PostList postsPromise={postsPromise} />
        </Suspense>
      </div>
    </ErrorBoundary>
  );
}
// A skeleton should MATCH THE SHAPE of the real content, or the layout
// jumps when it swaps in
function CardSkeleton() {
  return <div style={{ height: 64, background: '#eee', borderRadius: 8 }} />;
}
function ListSkeleton() {
  return <div style={{ height: 200, background: '#eee', borderRadius: 8 }} />;
}
// Inside a transition, React keeps the OLD content on screen instead of
// flashing back to the fallback. You get isPending to dim it instead.
function TabPanel() {
  const [tab, setTab] = useState('home');
  const [isPending, startTransition] = useTransition();
  return (
    <div>
      <button onClick={() => startTransition(() => setTab('posts'))}>
        Posts
      </button>
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        <Suspense fallback={<ListSkeleton />}>
          <TabContent tab={tab} />
        </Suspense>
      </div>
    </div>
  );
}
// THE BIG MISUNDERSTANDING: a plain fetch inside useEffect does NOT
// suspend. Suspense coordinates loading for resources that support it,
// it is not a data fetching mechanism of its own.

Was this explanation helpful?

Your feedback helps us improve the content for everyone.