On this page

Suspense and Error Boundaries: Declarative Loading and Error Handling

12 min read TextCh. 4 — Data and Forms

The Problem with Imperative Loading States

Without Suspense, every component that fetches data needs to explicitly manage loading and error states:

function ProductPage({ id }: { id: number }) {
  const { isLoading, isError, error, data } = useQuery({ ... });

  if (isLoading) return <Spinner />;
  if (isError) return <ErrorMessage error={error} />;

  return <Product data={data} />;
}

Multiply this across dozens of components and you have a lot of repetitive boilerplate. Suspense and Error Boundaries provide a declarative, composable alternative — separating the loading/error UI from the data-consuming component.

What is Suspense?

Suspense is a React component that shows a fallback UI while its children are "suspended" (waiting for something asynchronous). A component suspends by throwing a Promise — which React Suspense catches and uses to show the fallback until the Promise resolves.

import { Suspense } from 'react';

function App() {
  return (
    <Suspense fallback={<p>Loading application...</p>}>
      <MainContent />
    </Suspense>
  );
}

React.lazy and Code Splitting

The most common use of Suspense is code splitting with React.lazy. It allows you to load component code dynamically on demand:

import { lazy, Suspense } from 'react';

// Each lazy import creates a separate code chunk
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      {/* Only the code for the active route is loaded */}
      <RouterProvider router={router} />
    </Suspense>
  );
}

React.lazy accepts a function that returns a dynamic import(). The import must resolve to a module with a default export that is a React component.

The use() Hook with Promises

React 19 introduces the use() hook, which can suspend a component by unwrapping a Promise:

import { use, Suspense } from 'react';

interface Config {
  apiUrl: string;
  features: string[];
}

// Promise created outside component to avoid re-creation on render
const configPromise = fetch('/api/config').then((r) => r.json() as Promise<Config>);

function AppConfig() {
  // Suspends until configPromise resolves
  const config = use(configPromise);

  return (
    <div>
      <p>API: {config.apiUrl}</p>
      <ul>{config.features.map((f) => <li key={f}>{f}</li>)}</ul>
    </div>
  );
}

function App() {
  return (
    <Suspense fallback={<p>Loading configuration...</p>}>
      <AppConfig />
    </Suspense>
  );
}

The use() hook can also unwrap Context values conditionally — unlike useContext, it can be called inside if statements and loops.

Error Boundaries

Error Boundaries are class components that catch JavaScript errors during rendering, in lifecycle methods, and in constructors of their child tree. They prevent the entire app from crashing when a component throws.

import { Component, type ReactNode, type ErrorInfo } from 'react';

interface Props {
  fallback: ReactNode;
  children: ReactNode;
}

interface State {
  hasError: boolean;
}

class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };

  static getDerivedStateFromError(): State {
    return { hasError: true };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    // Log to error reporting service (Sentry, etc.)
    console.error('Uncaught error:', error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

Resettable Error Boundaries

For production use, you often want users to be able to retry after an error. Add a reset mechanism:

class ResettableErrorBoundary extends Component<Props & { onReset?: () => void }, State> {
  state: State = { hasError: false };

  static getDerivedStateFromError(): State {
    return { hasError: true };
  }

  handleReset = () => {
    this.setState({ hasError: false });
    this.props.onReset?.();
  };

  render() {
    if (this.state.hasError) {
      return (
        <div role="alert">
          <p>Something went wrong.</p>
          <button type="button" onClick={this.handleReset}>Try again</button>
        </div>
      );
    }
    return this.props.children;
  }
}

The react-error-boundary package provides this and more patterns as a ready-to-use library.

Suspense with TanStack Query

TanStack Query integrates with Suspense via useSuspenseQuery:

import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
import { ErrorBoundary } from './ErrorBoundary';

function UserCard({ userId }: { userId: number }) {
  // No isLoading or isError needed — Suspense/ErrorBoundary handle these
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
  });

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

function UserSection({ userId }: { userId: number }) {
  return (
    <ErrorBoundary fallback={<p role="alert">Failed to load user.</p>}>
      <Suspense fallback={<div className="skeleton-card" aria-busy="true" />}>
        <UserCard userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}

Skeleton Screens

Skeleton screens are a significantly better loading experience than spinners. They mimic the shape of the content being loaded:

function ArticleCardSkeleton() {
  return (
    <article className="article-card article-card--skeleton" aria-busy="true" aria-label="Loading article">
      <div className="skeleton-image" />
      <div className="skeleton-line skeleton-line--title" />
      <div className="skeleton-line" />
      <div className="skeleton-line skeleton-line--short" />
    </article>
  );
}

function ArticleListSkeleton({ count = 3 }: { count?: number }) {
  return (
    <>
      {Array.from({ length: count }, (_, i) => (
        <ArticleCardSkeleton key={i} />
      ))}
    </>
  );
}

function ArticleList() {
  return (
    <Suspense fallback={<ArticleListSkeleton count={6} />}>
      <ArticleListContent />
    </Suspense>
  );
}

The aria-busy="true" attribute tells screen readers that the content is loading, and aria-label provides a text description of what is loading.

Nested Suspense Boundaries

You can nest Suspense boundaries at different levels to progressively reveal content:

function ProductPage({ productId }: { productId: number }) {
  return (
    <div>
      {/* Product info loads first */}
      <Suspense fallback={<ProductInfoSkeleton />}>
        <ProductInfo productId={productId} />

        {/* Reviews load independently — won't block ProductInfo */}
        <Suspense fallback={<ReviewsSkeleton />}>
          <ProductReviews productId={productId} />
        </Suspense>
      </Suspense>
    </div>
  );
}

In the next lesson, you will learn about global state management with Zustand — a minimal, scalable alternative to Redux.

Compose Suspense and ErrorBoundary as a pair
Always wrap `<Suspense>` with an `<ErrorBoundary>`. Suspense handles the loading state; ErrorBoundary handles the error state. Together they give you complete declarative control over async rendering. You can nest them at different granularities for fine-grained UX.
TanStack Query supports Suspense mode
Pass `{ suspense: true }` to `useQuery` (or use `useSuspenseQuery`) to integrate TanStack Query with React Suspense. The query will suspend the component while loading and throw an error for the boundary to catch. This eliminates `isLoading` and `isError` checks from your component.
ErrorBoundary must be a class component
React's error boundary API (`getDerivedStateFromError` and `componentDidCatch`) is only available in class components — there is no hook equivalent. Use the class directly or install the `react-error-boundary` package for a functional-friendly wrapper with reset capabilities.
import { Suspense, lazy, use } from 'react';
import { ErrorBoundary } from './ErrorBoundary';

// Lazy-loaded component — code is split into a separate chunk
const HeavyChart = lazy(() => import('./HeavyChart'));

interface User {
  id: number;
  name: string;
  email: string;
}

function UserInfo({ userPromise }: { userPromise: Promise<User> }) {
  // use() suspends the component until the promise resolves
  const user = use(userPromise);
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

function UserProfileSkeleton() {
  return (
    <div className="skeleton" aria-busy="true" aria-label="Loading user profile">
      <div className="skeleton__line skeleton__line--title" />
      <div className="skeleton__line skeleton__line--text" />
    </div>
  );
}

function ChartSkeleton() {
  return (
    <div
      className="skeleton skeleton--chart"
      aria-busy="true"
      aria-label="Loading chart"
    />
  );
}

function UserDashboard({ userId }: { userId: number }) {
  const userPromise = fetch(`/api/users/${userId}`).then(
    (r) => r.json() as Promise<User>
  );

  return (
    <div className="dashboard">
      <ErrorBoundary fallback={<p role="alert">Failed to load user.</p>}>
        <Suspense fallback={<UserProfileSkeleton />}>
          <UserInfo userPromise={userPromise} />
        </Suspense>
      </ErrorBoundary>

      <ErrorBoundary fallback={<p role="alert">Chart unavailable.</p>}>
        <Suspense fallback={<ChartSkeleton />}>
          <HeavyChart userId={userId} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

export default UserDashboard;