C.W.K.
Stream
Lesson 02 of 07 · published

Suspense — Where the Loading State Lives

~13 min · suspense, boundary, fallback

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
Suspense is a boundary that catches suspension. Place it where you want the loading UI to live. The component that actually fetches doesn't need to know about loading at all.

The mental model

Imagine your component tree as a hierarchy of <Suspense fallback={...}> boundaries. When any descendant calls use(somePendingPromise), React 'throws' (suspends), walks up the tree until it finds the nearest Suspense, and renders that boundary's fallback. When the promise resolves, React re-renders the suspended subtree and the fallback is replaced with real content.

You decide where the boundary sits. A boundary at the top of the page means the whole page shows a spinner; a boundary around a single panel means only that panel shows the spinner while the rest renders normally.

Granularity

Coarse: <Suspense fallback={<FullPageSpinner />}><App /></Suspense>. Easy, ugly UX.

Fine: a Suspense around the sidebar, another around the main panel, another around each list row. Beautiful streaming UX where parts of the page become interactive at their own pace.

Realistic: a couple of strategic boundaries. The page shell renders instantly, a Suspense around the data section streams in when ready. Don't try to over-granularize on day one.

Nested Suspense

Boundaries nest naturally. An inner boundary catches its own descendants' suspension first; if there's no inner boundary, the outer one takes over. Use nesting to scope the spinner to the smallest meaningful region.

The transition trick

When a user action (search, filter change) triggers new data, the page would normally flash to the Suspense fallback. useTransition (Track 7) tells React 'this state change is non-urgent; keep showing the old UI until the new one is ready.' The result is a smooth swap instead of a flash of spinner.

Place the boundary at the smallest meaningful region. The narrower the boundary, the more of the page stays interactive while data loads. Over-granular adds noise; under-granular wastes the streaming win. Pick boundaries that match how users perceive 'sections' of your UI.

Code

A page with strategic Suspense placement·tsx
import { Suspense } from "react";
import { ConversationList } from "./ConversationList";
import { ConversationDetail } from "./ConversationDetail";

function ChatPage({ activeId }: { activeId: string }) {
  return (
    <div className="flex h-screen">
      {/* Sidebar streams in independently */}
      <aside className="w-64 border-r">
        <Suspense fallback={<SidebarSkeleton />}>
          <ConversationList />
        </Suspense>
      </aside>

      {/* Main panel streams independently */}
      <main className="flex-1">
        <Suspense fallback={<DetailSkeleton />}>
          <ConversationDetail id={activeId} />
        </Suspense>
      </main>
    </div>
  );
}

// The page shell, the headers, the navigation — all render instantly.
// Sidebar and main panel fill in as their data resolves.
Nested Suspense — inner catches first·tsx
function Profile({ userId }: { userId: string }) {
  return (
    <Suspense fallback={<p>Loading profile…</p>}>
      <UserHeader userId={userId} />
      {/* Nested boundary scopes the slower data to its own loading UI */}
      <Suspense fallback={<p>Loading posts…</p>}>
        <UserPosts userId={userId} />
      </Suspense>
    </Suspense>
  );
}

// UserHeader resolves → it renders. UserPosts is still loading → only the
// nested boundary's fallback shows, the header stays visible.

External links

Exercise

Build a two-pane layout (sidebar + main) where each pane fetches different data. Place Suspense boundaries so the two panes load independently. Then deliberately make one fetch slow (a Promise that resolves after 3 seconds) and verify the other pane still renders without waiting. Add a nested Suspense inside the main pane around a secondary widget to see the hierarchy in action.
Hint
For the slow fetch, wrap your real fetch in new Promise((r) => setTimeout(() => fetch(...).then(j => r(j)), 3000)). The fast pane should appear immediately; the slow pane's skeleton stays until the timeout.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.