Suspense: The Loading State Revolution

SS Saurav Sitaula

Master React Suspense for declarative loading states. Learn React.lazy for code splitting, Suspense boundaries for coordinated loading, streaming with React 18, useTransition for smooth transitions, and integrating with React Query/SWR. Eliminate scattered isLoading checks forever.

The Loading State Nightmare

After implementing error boundaries, I felt good about handling failures. But there was another pattern cluttering my code: loading states.

Every. Single. Component.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    setLoading(true);
    fetchUser(userId)
      .then(setUser)
      .finally(() => setLoading(false));
  }, [userId]);
  
  if (loading) return <Spinner />;
  return <Profile user={user} />;
}

function UserPosts({ userId }) {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    setLoading(true);
    fetchPosts(userId)
      .then(setPosts)
      .finally(() => setLoading(false));
  }, [userId]);
  
  if (loading) return <Spinner />;
  return <PostList posts={posts} />;
}

function UserFriends({ userId }) {
  const [friends, setFriends] = useState([]);
  const [loading, setLoading] = useState(true);
  
  // You get the idea...
}

The same pattern, copy-pasted everywhere. And when these components rendered together?

function Dashboard() {
  return (
    <div>
      <UserProfile userId={1} />   {/* Spinner */}
      <UserPosts userId={1} />     {/* Spinner */}
      <UserFriends userId={1} />   {/* Spinner */}
    </div>
  );
}

Three spinners. Three independent loading states. The user sees things pop in one by one, creating a janky experience.

Enter: Suspense

Suspense is React’s way of handling async operations declaratively. Instead of each component managing its own loading state, you declare loading boundaries—similar to error boundaries.

import { Suspense } from 'react';

function Dashboard() {
  return (
    <Suspense fallback={<DashboardSkeleton />}>
      <UserProfile userId={1} />
      <UserPosts userId={1} />
      <UserFriends userId={1} />
    </Suspense>
  );
}

All three components load together, showing one skeleton while they fetch, then revealing the content all at once.

But here’s the catch: Suspense doesn’t work with regular useEffect + useState fetching. It needs special support.

How Suspense Actually Works

Suspense relies on components “suspending” during render. When a component suspends, React pauses rendering it and shows the nearest Suspense fallback instead.

The magic happens through a protocol: components throw a Promise while they’re loading, and resolve when ready.

// This is conceptual - don't write this yourself
function UserProfile({ userId }) {
  // This "suspends" if data isn't ready
  const user = readUser(userId);  // Throws promise if loading, returns data if ready
  
  return <Profile user={user} />;
}

You don’t see if (loading) because suspending handles it automatically.

Making Suspense Work: Data Fetching Libraries

You typically don’t implement the suspend/throw mechanism yourself. Libraries do it for you.

React Query with Suspense

import { useQuery } from '@tanstack/react-query';

function UserProfile({ userId }) {
  // With suspense: true, this component will suspend while loading
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    suspense: true,
  });
  
  // No loading check! Data is guaranteed to exist here
  return <Profile user={user} />;
}

function Dashboard() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile userId={1} />
    </Suspense>
  );
}

SWR with Suspense

import useSWR from 'swr';

function UserProfile({ userId }) {
  const { data: user } = useSWR(
    `/api/users/${userId}`, 
    fetcher,
    { suspense: true }
  );
  
  return <Profile user={user} />;
}

Relay (Facebook’s GraphQL client)

Relay was built with Suspense in mind from the start. If you’re using GraphQL, it’s worth a look.

Suspense for Code Splitting: React.lazy

Before Suspense for data, there was Suspense for code splitting. React.lazy lets you load components on demand:

// Without lazy - bundled with main app
import HeavyChart from './HeavyChart';

// With lazy - loaded only when needed
const HeavyChart = React.lazy(() => import('./HeavyChart'));

function Dashboard({ showChart }) {
  return (
    <div>
      <Header />
      {showChart && (
        <Suspense fallback={<ChartSkeleton />}>
          <HeavyChart />  {/* Loaded on demand */}
        </Suspense>
      )}
    </div>
  );
}

The HeavyChart component is split into a separate bundle. It only loads when showChart is true. While loading, users see ChartSkeleton.

This was huge for my app’s initial load time. The main bundle went from 800KB to 300KB.

Nested Suspense Boundaries

Like error boundaries, you can nest Suspense boundaries for granular control:

function Dashboard() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Header />
      
      <div className="content">
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>
        
        <main>
          <Suspense fallback={<ContentSkeleton />}>
            <MainContent />
          </Suspense>
        </main>
      </div>
    </Suspense>
  );
}

If MainContent takes longer to load, users still see the Header and Sidebar while waiting.

The Waterfall Problem

Here’s something that bit me: naive Suspense usage can create request waterfalls.

function Dashboard() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile />  {/* Fetches user, suspends */}
      <UserPosts />    {/* Waits for UserProfile to unsuspend before even starting */}
    </Suspense>
  );
}

If these are in the same Suspense boundary, the second fetch doesn’t start until the first resolves. Sequential requests = slow.

Solution 1: Parallel Data Fetching

Start all fetches before rendering:

function Dashboard({ userId }) {
  // Start both fetches immediately
  const userPromise = fetchUser(userId);
  const postsPromise = fetchPosts(userId);
  
  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile userPromise={userPromise} />
      <UserPosts postsPromise={postsPromise} />
    </Suspense>
  );
}

Solution 2: Separate Suspense Boundaries

function Dashboard() {
  return (
    <>
      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfile />  {/* Independent loading */}
      </Suspense>
      <Suspense fallback={<PostsSkeleton />}>
        <UserPosts />    {/* Starts immediately, doesn't wait */}
      </Suspense>
    </>
  );
}

Both components fetch in parallel, and each can render as soon as its data arrives.

Solution 3: React Query’s Prefetching

function Dashboard({ userId }) {
  const queryClient = useQueryClient();
  
  // Prefetch both queries on mount
  useEffect(() => {
    queryClient.prefetchQuery(['user', userId], () => fetchUser(userId));
    queryClient.prefetchQuery(['posts', userId], () => fetchPosts(userId));
  }, [userId]);
  
  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile userId={userId} />
      <UserPosts userId={userId} />
    </Suspense>
  );
}

Suspense + Error Boundaries = Complete Coverage

Remember error boundaries? They pair beautifully with Suspense:

function Dashboard() {
  return (
    <ErrorBoundary fallback={<ErrorMessage />}>
      <Suspense fallback={<DashboardSkeleton />}>
        <DashboardContent />
      </Suspense>
    </ErrorBoundary>
  );
}
  • While loading: Show skeleton (Suspense)
  • On error: Show error message (ErrorBoundary)
  • On success: Show content

The order matters! Error boundary should wrap Suspense so errors during loading are also caught.

SuspenseList: Coordinating Multiple Suspense Boundaries

React 18 introduced SuspenseList for coordinating how multiple Suspense boundaries reveal:

import { SuspenseList, Suspense } from 'react';

function Feed() {
  return (
    <SuspenseList revealOrder="forwards">
      <Suspense fallback={<PostSkeleton />}>
        <Post id={1} />
      </Suspense>
      <Suspense fallback={<PostSkeleton />}>
        <Post id={2} />
      </Suspense>
      <Suspense fallback={<PostSkeleton />}>
        <Post id={3} />
      </Suspense>
    </SuspenseList>
  );
}

With revealOrder="forwards", posts reveal top-to-bottom, even if post #3 loads before post #1. No content jumping.

Other options:

  • "backwards" - Reveal bottom-to-top
  • "together" - Reveal all at once when all are ready

Transitions: Avoiding Unwanted Fallbacks

Sometimes showing a loading fallback is worse than showing stale content. React 18’s useTransition helps:

function TabContainer() {
  const [tab, setTab] = useState('home');
  const [isPending, startTransition] = useTransition();
  
  function selectTab(nextTab) {
    startTransition(() => {
      setTab(nextTab);
    });
  }
  
  return (
    <>
      <TabButtons onSelect={selectTab} />
      <div style={{ opacity: isPending ? 0.7 : 1 }}>
        <Suspense fallback={<Spinner />}>
          {tab === 'home' && <HomeTab />}
          {tab === 'profile' && <ProfileTab />}
          {tab === 'settings' && <SettingsTab />}
        </Suspense>
      </div>
    </>
  );
}

When switching tabs, React keeps showing the old tab (slightly dimmed via isPending) until the new tab is ready. No spinner flash for fast loads.

The Mental Model Shift

Before Suspense, I thought imperatively:

  1. Start loading
  2. Track loading state
  3. Conditionally render based on state

With Suspense, I think declaratively:

  1. Define what loading looks like (fallback)
  2. Define what error looks like (error boundary)
  3. Just render the component—React handles the rest
// Old mental model
function MyComponent() {
  if (loading) return <Loading />;
  if (error) return <Error />;
  return <Content data={data} />;
}

// Suspense mental model
function MyComponent() {
  return <Content data={data} />;  // Just render!
}

// Wrap with boundaries
<ErrorBoundary fallback={<Error />}>
  <Suspense fallback={<Loading />}>
    <MyComponent />
  </Suspense>
</ErrorBoundary>

Components become simpler. They just render. Loading and error states are handled at boundaries.

What I Wish I’d Known Earlier

  1. Suspense requires library support. You can’t just wrap useEffect fetching in Suspense and expect it to work. Use React Query, SWR, Relay, or similar.

  2. Start with React.lazy. Even if you’re not ready for Suspense data fetching, code splitting with React.lazy is production-ready today.

  3. Boundaries are reusable patterns. Just like error boundaries, design your Suspense boundaries once and reuse them.

  4. Waterfalls are sneaky. Nested components in one Suspense boundary can create sequential requests. Profile your network tab.

  5. Transitions smooth the experience. For tab switches and navigation, useTransition prevents jarring loading states.

  6. Server components change everything. Suspense on the server opens up new patterns. But that’s the next evolution…

The Journey Continues

Suspense was a paradigm shift in how I think about loading states. Combined with error boundaries, I now have a complete story for success, loading, and error states.

But I kept hearing about “server components”—React components that run on the server and send HTML to the client. No JavaScript bundle. No hydration. It sounded too good to be true.

Time to dive into the future of React.


P.S. — The first time I used Suspense with React Query, I deleted 200 lines of loading state management code. Two. Hundred. Lines. Gone. Replaced by a few Suspense boundaries. That’s when I knew this was the right abstraction.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism