Difficulty: Advanced
React Suspense is a mechanism that lets components 'wait' for something before rendering - whether that is lazily loaded code, data, or other asynchronous resources. When a component inside a Suspense boundary is not ready, React shows the fallback UI (typically a loading spinner) until the component resolves.
The most established use of Suspense is with React.lazy for code splitting. React.lazy takes a function that returns a dynamic import and creates a component that loads the module on demand. Wrapping it with Suspense provides the loading state. This is the foundation of route-based code splitting - instead of loading your entire app's JavaScript upfront, each route's code loads only when the user navigates to it.
Route-based code splitting is the most impactful optimization because routes represent natural boundaries where users expect a brief loading moment. You lazy-load each page component and wrap your routes in a Suspense boundary. The initial bundle becomes much smaller, and subsequent routes load on demand. This dramatically improves Time to Interactive for large applications.
Suspense for data fetching is a newer pattern. Libraries like React Query (with its suspense option) and the experimental React.use hook let components suspend while data is loading. Instead of managing isLoading state manually, the component simply reads the data and Suspense handles the loading state. This leads to cleaner component code - no loading/error conditionals.
Suspense boundaries can be nested. A page-level boundary shows a full-page loader while the route loads. Inside that page, section-level boundaries can show inline spinners while individual data sources load. This gives you fine-grained control over the loading experience without cluttering components with loading logic.
import React, { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
// Lazy-loaded route components
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Profile = lazy(() => import('./pages/Profile'));
function App() {
return (
<Suspense fallback={<div className="loading">Loading page...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</Suspense>
);
}
Each route component is in a separate chunk. The browser only downloads Dashboard.js when the user navigates to /dashboard. Suspense shows the fallback during download.
function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading stats...</p>}>
<StatsWidget />
</Suspense>
<Suspense fallback={<p>Loading chart...</p>}>
<ChartWidget />
</Suspense>
<Suspense fallback={<p>Loading activity...</p>}>
<ActivityFeed />
</Suspense>
</div>
);
}
Nested boundaries let each section load independently. The stats might appear first while the chart and activity feed are still loading. Users see progressive content instead of one big spinner.
import { useSuspenseQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
// No loading state needed - Suspense handles it
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// data is guaranteed to exist here
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
// Wrap with Suspense
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={1} />
</Suspense>
useSuspenseQuery suspends the component while data loads. The component code is cleaner - no isLoading check, data is always defined. The Suspense boundary handles the loading state.
React.Suspense, React.lazy, lazy loading, code splitting, fallback UI, concurrent features