Skip to content
Codeloom
React

React Suspense for Data Fetching with the use() Hook

Learn how to use React's Suspense boundaries and the use() hook to handle async data fetching with elegant loading and error states.

·5 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How Suspense works for data fetching
  • The use() hook and how it reads promises
  • Structuring loading and error boundaries
  • Streaming patterns with nested Suspense
  • Practical examples with fetch and context

Prerequisites

  • Comfortable with React components
  • Basic understanding of Promises

React Suspense lets you declaratively specify what to show while async data is loading. Instead of tracking loading booleans and rendering spinners manually, you wrap components in a <Suspense> boundary and let React handle the rest. The use() hook, introduced in React 19, is the key to reading promises inside components.

The Core Idea

Suspense inverts the loading state pattern. Instead of each component managing its own isLoading flag, a component simply reads data as if it were already available. If the data is not ready, React “suspends” the component and shows the nearest <Suspense> boundary’s fallback.

import { Suspense } from 'react';

function App() {
  return (
    <Suspense fallback={<p>Loading user...</p>}>
      <UserProfile userId={42} />
    </Suspense>
  );
}

When UserProfile suspends because its data is not ready, React renders the fallback. When the data arrives, React replaces the fallback with the actual component.

The use() Hook

The use() hook reads the value from a promise. If the promise is still pending, the component suspends. If it resolved, the hook returns the value. If it rejected, the nearest error boundary catches the error.

import { use } from 'react';

function UserProfile({ userId }) {
  const userPromise = fetchUser(userId);
  const user = use(userPromise);

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

There is an important subtlety here. You should not create the promise inside the rendering component, because each render would create a new promise, causing an infinite suspend loop. Instead, create the promise in a parent component or a cache and pass it down.

Passing Promises as Props

The recommended pattern is to start the fetch in a parent component (or a Server Component) and pass the promise as a prop.

import { Suspense } from 'react';

function DashboardPage() {
  // Start fetching immediately, don't await
  const userPromise = fetchUser();
  const statsPromise = fetchStats();

  return (
    <div className="dashboard">
      <Suspense fallback={<UserSkeleton />}>
        <UserCard userPromise={userPromise} />
      </Suspense>
      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel statsPromise={statsPromise} />
      </Suspense>
    </div>
  );
}

function UserCard({ userPromise }) {
  const user = use(userPromise);
  return <div className="card">{user.name}</div>;
}

function StatsPanel({ statsPromise }) {
  const stats = use(statsPromise);
  return (
    <div className="stats">
      <p>Posts: {stats.postCount}</p>
      <p>Followers: {stats.followers}</p>
    </div>
  );
}

Both fetches start simultaneously. Each component independently suspends and resolves. The user sees whichever data arrives first, without either blocking the other.

Error Boundaries for Rejected Promises

When a promise passed to use() rejects, React throws the error to the nearest error boundary. You can use the standard ErrorBoundary pattern or a library like react-error-boundary.

import { ErrorBoundary } from 'react-error-boundary';

function App() {
  const dataPromise = fetchData();

  return (
    <ErrorBoundary fallback={<p>Something went wrong.</p>}>
      <Suspense fallback={<p>Loading...</p>}>
        <DataView dataPromise={dataPromise} />
      </Suspense>
    </ErrorBoundary>
  );
}

The boundary catches the rejection and renders the fallback. You can add a retry button by using the resetErrorBoundary function.

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <p>Failed to load: {error.message}</p>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

use() with Context

The use() hook is not limited to promises. It can also read React context, and unlike useContext, it can be called conditionally.

import { use, createContext } from 'react';

const ThemeContext = createContext('light');

function ThemedButton({ isSpecial }) {
  if (isSpecial) {
    const theme = use(ThemeContext);
    return <button className={`btn-${theme}`}>Special</button>;
  }
  return <button>Normal</button>;
}

This is valid because use() does not follow the rules of hooks. It can appear inside conditions, loops, and early returns. This flexibility is intentional and makes it more versatile than useContext.

Nested Suspense for Streaming

You can nest <Suspense> boundaries to create a streaming experience where parts of the page load progressively.

function ProductPage({ productId }) {
  const productPromise = fetchProduct(productId);
  const reviewsPromise = fetchReviews(productId);
  const relatedPromise = fetchRelated(productId);

  return (
    <div>
      <Suspense fallback={<ProductSkeleton />}>
        <ProductDetails productPromise={productPromise} />

        <Suspense fallback={<ReviewsSkeleton />}>
          <Reviews reviewsPromise={reviewsPromise} />
        </Suspense>

        <Suspense fallback={<RelatedSkeleton />}>
          <RelatedProducts relatedPromise={relatedPromise} />
        </Suspense>
      </Suspense>
    </div>
  );
}

The product details appear first. Reviews and related products stream in as their data arrives. Each section has its own skeleton, and none blocks the others.

Caching Promises

To avoid re-fetching on every render, cache your promises. React itself caches promises passed to use() during a single render pass, but across navigations or re-mounts, you need your own cache or a library like TanStack Query.

const cache = new Map();

function fetchWithCache(url) {
  if (!cache.has(url)) {
    cache.set(url, fetch(url).then(r => r.json()));
  }
  return cache.get(url);
}

function UserList() {
  const usersPromise = fetchWithCache('/api/users');
  const users = use(usersPromise);

  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

For production applications, use a proper caching layer rather than a bare Map. Libraries like TanStack Query, SWR, or framework-level caches handle invalidation, deduplication, and garbage collection.

When to Use Suspense for Data Fetching

Use Suspense when you want to eliminate loading state boilerplate, enable parallel data fetching, or build streaming server-rendered pages. It pairs naturally with Server Components, which can pass promises to Client Components as props. For simple, local data fetching in a single component, useEffect with useState still works fine, but Suspense with use() is the direction React is heading.