On this page
Suspense and Error Boundaries: Declarative Loading and Error Handling
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.
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;
Sign in to track your progress