Skip to content

React Tutorial Advanced React Concepts

Suspense in React: Fallbacks, lazy and the use Hook

Lesson 121 of 156 3 min read By Deepak Paswan Updated Oct 10, 2026

Runnable lesson · press Try it on the examples to edit and run them here.

Suspense lets a component say “I am not ready yet” while something loads, and lets a parent decide what to show meanwhile. You have met it with lazy. This lesson explains the idea and shows the other thing that can suspend: reading a promise with use.

How it works

  1. A component starts something that is not ready (a lazy chunk, or a data promise it reads with use).
  2. React pauses rendering that component and looks upward for the nearest <Suspense>.
  3. The boundary renders its fallback instead.
  4. When the thing is ready, React retries and shows the real content.
App.jsx
JSX
// Create the promise OUTSIDE the component (or cache it), not during every render
const reportPromise = new Promise((resolve) => setTimeout(() => resolve("report data"), 700));

function Report() {
  const data = React.use(reportPromise); // suspends until the promise resolves
  return <p>Loaded: {data}</p>;
}

function App() {
  return (
    <div>
      <h3>Page</h3>
      <React.Suspense fallback={<p>Loading the report...</p>}>
        <Report />
      </React.Suspense>
    </div>
  );
}

use(promise) reads the value of a promise during rendering. While it is pending, the component suspends and Suspense shows the fallback. If the promise rejects, the nearest error boundary takes over (Error Boundaries).

warning
Create the promise outside render
If you write use(fetch(...)) inside a component, a new promise is created on every render, so it never settles and you get an endless suspend loop. The promise must be created in a parent, a cache, a route loader or a library that caches it. That is why data libraries and frameworks usually provide this part. Learn the idea here; for real data fetching, use a tool that manages the promise for you (Data-Fetching Libraries).

Nested boundaries: independent loading

Place Suspense boundaries around independent parts, so one slow part does not block the rest:

App.jsx
JSX
const fast = new Promise((r) => setTimeout(() => r("Fast widget ready"), 300));
const slow = new Promise((r) => setTimeout(() => r("Slow widget ready"), 1500));

function Widget({ promise }) {
  return <p>{React.use(promise)}</p>;
}

function App() {
  return (
    <div>
      <h3>Header</h3>
      <React.Suspense fallback={<p>Loading fast widget...</p>}>
        <Widget promise={fast} />
      </React.Suspense>
      <React.Suspense fallback={<p>Loading slow widget...</p>}>
        <Widget promise={slow} />
      </React.Suspense>
    </div>
  );
}

The header shows immediately, the fast widget appears after 300 ms and the slow one later. Each boundary reveals its own content when ready. With one boundary around everything, nothing would appear until the slowest part is ready.

Fallbacks that fit

  • Use a skeleton that matches the final layout, to avoid jumping (Loading States).
  • Put the boundary close to what loads, not at the root, unless you want a full-page loader.
  • Keep fallbacks light; they should render instantly.

Avoid hiding content that is already visible

If a boundary is already showing content and something inside starts loading again (for example, after a click that changes the data), React normally swaps the content back to the fallback. That flashes the screen. Wrap the state update in a transition and React keeps the old content visible until the new content is ready:

App.jsx
JSX
const postsPromise = new Promise((r) => setTimeout(() => r("Posts are ready"), 1800));

function Posts() {
  return <p>{React.use(postsPromise)}</p>;
}

function App() {
  const [tab, setTab] = React.useState("home");
  const [isPending, startTransition] = React.useTransition();

  return (
    <div>
      <p>Tab: {tab}{isPending ? " (switching...)" : ""}</p>
      <button onClick={() => startTransition(() => setTab("posts"))}>Open posts</button>
      <React.Suspense fallback={<p>Loading posts...</p>}>
        {tab === "posts" ? <Posts /> : <p>Welcome home</p>}
      </React.Suspense>
    </div>
  );
}

Because the update happens in startTransition, React keeps the old tab on screen and gives you isPending until the new one is ready. The old tab stays on screen, with (switching...) as a hint, until the posts are ready. Without the transition, the Suspense fallback (Loading posts...) would replace the visible content instead.

Suspense and the server

On the server, Suspense also lets React stream HTML: send the page shell at once and fill in slow parts as they become ready. That belongs to frameworks and is out of scope for a client-only Vite app, but it is a big reason Suspense exists. See Client-Side React vs React Frameworks.

What can suspend?

  • lazy() components.
  • Components that call use(promise).
  • Data libraries and frameworks that support Suspense.

Ordinary fetch inside useEffect does not trigger Suspense. The component renders first, then loads, and manages its own loading state.

Key points

  • Suspense shows a fallback while a child is not ready (lazy code or a promise read with use).
  • Place boundaries around independent parts; wrap updates in transitions to keep visible content.
  • Create or cache promises outside render; a library or framework usually does that for you.