Suspense handles async operations with declarative loading states. Here's how to use it effectively.
Basic Suspense#
1import { Suspense } from 'react';
2
3function App() {
4 return (
5 <Suspense fallback={<Loading />}>
6 <AsyncComponent />
7 </Suspense>
8 );
9}
10
11function Loading() {
12 return <div className="spinner">Loading...</div>;
13}
14
15// With lazy loading
16const LazyComponent = lazy(() => import('./HeavyComponent'));
17
18function App() {
19 return (
20 <Suspense fallback={<Skeleton />}>
21 <LazyComponent />
22 </Suspense>
23 );
24}Nested Suspense Boundaries#
1function Dashboard() {
2 return (
3 <Suspense fallback={<PageSkeleton />}>
4 <Header />
5
6 <main>
7 {/* Independent loading states */}
8 <Suspense fallback={<ChartSkeleton />}>
9 <Charts />
10 </Suspense>
11
12 <Suspense fallback={<TableSkeleton />}>
13 <DataTable />
14 </Suspense>
15
16 <Suspense fallback={<ActivitySkeleton />}>
17 <RecentActivity />
18 </Suspense>
19 </main>
20 </Suspense>
21 );
22}
23
24// Each section loads independently
25// Fast sections appear firstSuspense with Data Fetching#
1// Using React Query
2import { useSuspenseQuery } from '@tanstack/react-query';
3
4function UserProfile({ userId }: { userId: string }) {
5 const { data } = useSuspenseQuery({
6 queryKey: ['user', userId],
7 queryFn: () => fetchUser(userId),
8 });
9
10 return (
11 <div>
12 <h1>{data.name}</h1>
13 <p>{data.email}</p>
14 </div>
15 );
16}
17
18function App() {
19 return (
20 <Suspense fallback={<ProfileSkeleton />}>
21 <UserProfile userId="123" />
22 </Suspense>
23 );
24}
25
26// Using SWR
27import useSWR from 'swr';
28
29function UserProfile({ userId }: { userId: string }) {
30 const { data } = useSWR(`/api/users/${userId}`, fetcher, {
31 suspense: true,
32 });
33
34 return <div>{data.name}</div>;
35}Error Boundaries with Suspense#
1import { ErrorBoundary } from 'react-error-boundary';
2
3function App() {
4 return (
5 <ErrorBoundary
6 fallback={<ErrorMessage />}
7 onError={(error) => logError(error)}
8 >
9 <Suspense fallback={<Loading />}>
10 <AsyncContent />
11 </Suspense>
12 </ErrorBoundary>
13 );
14}
15
16// Combined error and loading states
17function AsyncSection({ children }: { children: React.ReactNode }) {
18 return (
19 <ErrorBoundary
20 fallback={({ error, resetErrorBoundary }) => (
21 <div className="error">
22 <p>Error: {error.message}</p>
23 <button onClick={resetErrorBoundary}>Retry</button>
24 </div>
25 )}
26 >
27 <Suspense fallback={<SectionSkeleton />}>
28 {children}
29 </Suspense>
30 </ErrorBoundary>
31 );
32}
33
34function Dashboard() {
35 return (
36 <div>
37 <AsyncSection>
38 <UserStats />
39 </AsyncSection>
40
41 <AsyncSection>
42 <RecentOrders />
43 </AsyncSection>
44 </div>
45 );
46}Streaming SSR with Suspense#
1// Next.js App Router
2// app/page.tsx
3import { Suspense } from 'react';
4
5export default function Page() {
6 return (
7 <div>
8 <h1>Dashboard</h1>
9
10 {/* Static content renders immediately */}
11 <StaticHeader />
12
13 {/* Async content streams in */}
14 <Suspense fallback={<UserSkeleton />}>
15 <UserInfo />
16 </Suspense>
17
18 <Suspense fallback={<StatsSkeleton />}>
19 <Stats />
20 </Suspense>
21 </div>
22 );
23}
24
25// Server component with async data
26async function UserInfo() {
27 const user = await fetchUser();
28 return <div>{user.name}</div>;
29}
30
31async function Stats() {
32 const stats = await fetchStats();
33 return (
34 <div>
35 <p>Total: {stats.total}</p>
36 </div>
37 );
38}SuspenseList (Experimental)#
1import { SuspenseList, Suspense } from 'react';
2
3function Feed() {
4 return (
5 <SuspenseList revealOrder="forwards" tail="collapsed">
6 <Suspense fallback={<PostSkeleton />}>
7 <Post id="1" />
8 </Suspense>
9 <Suspense fallback={<PostSkeleton />}>
10 <Post id="2" />
11 </Suspense>
12 <Suspense fallback={<PostSkeleton />}>
13 <Post id="3" />
14 </Suspense>
15 </SuspenseList>
16 );
17}
18
19// revealOrder options:
20// - "forwards": Top to bottom
21// - "backwards": Bottom to top
22// - "together": All at once
23
24// tail options:
25// - "collapsed": Show one fallback at a time
26// - "hidden": Don't show fallbacksTransitions with Suspense#
1import { useState, useTransition, Suspense } from 'react';
2
3function TabContainer() {
4 const [tab, setTab] = useState('home');
5 const [isPending, startTransition] = useTransition();
6
7 function selectTab(nextTab: string) {
8 startTransition(() => {
9 setTab(nextTab);
10 });
11 }
12
13 return (
14 <div>
15 <nav style={{ opacity: isPending ? 0.7 : 1 }}>
16 <button onClick={() => selectTab('home')}>Home</button>
17 <button onClick={() => selectTab('profile')}>Profile</button>
18 <button onClick={() => selectTab('settings')}>Settings</button>
19 </nav>
20
21 <Suspense fallback={<TabSkeleton />}>
22 {tab === 'home' && <HomeTab />}
23 {tab === 'profile' && <ProfileTab />}
24 {tab === 'settings' && <SettingsTab />}
25 </Suspense>
26 </div>
27 );
28}Deferred Values#
1import { useDeferredValue, Suspense, useState } from 'react';
2
3function SearchResults() {
4 const [query, setQuery] = useState('');
5 const deferredQuery = useDeferredValue(query);
6 const isStale = query !== deferredQuery;
7
8 return (
9 <div>
10 <input
11 value={query}
12 onChange={(e) => setQuery(e.target.value)}
13 placeholder="Search..."
14 />
15
16 <div style={{ opacity: isStale ? 0.5 : 1 }}>
17 <Suspense fallback={<ResultsSkeleton />}>
18 <Results query={deferredQuery} />
19 </Suspense>
20 </div>
21 </div>
22 );
23}
24
25// Results component can suspend
26function Results({ query }: { query: string }) {
27 const results = use(fetchResults(query));
28
29 return (
30 <ul>
31 {results.map((result) => (
32 <li key={result.id}>{result.title}</li>
33 ))}
34 </ul>
35 );
36}Custom Suspense Hook#
1// Create suspense-enabled resource
2function createResource<T>(promise: Promise<T>) {
3 let status: 'pending' | 'success' | 'error' = 'pending';
4 let result: T;
5 let error: Error;
6
7 const suspender = promise.then(
8 (data) => {
9 status = 'success';
10 result = data;
11 },
12 (e) => {
13 status = 'error';
14 error = e;
15 }
16 );
17
18 return {
19 read(): T {
20 switch (status) {
21 case 'pending':
22 throw suspender;
23 case 'error':
24 throw error;
25 case 'success':
26 return result;
27 }
28 },
29 };
30}
31
32// Usage
33const userResource = createResource(fetchUser());
34
35function UserProfile() {
36 const user = userResource.read();
37 return <div>{user.name}</div>;
38}
39
40function App() {
41 return (
42 <Suspense fallback={<Loading />}>
43 <UserProfile />
44 </Suspense>
45 );
46}Loading UI Patterns#
1// Skeleton screens
2function CardSkeleton() {
3 return (
4 <div className="card-skeleton">
5 <div className="skeleton-image" />
6 <div className="skeleton-title" />
7 <div className="skeleton-text" />
8 <div className="skeleton-text" />
9 </div>
10 );
11}
12
13// Progressive loading
14function Article() {
15 return (
16 <article>
17 {/* Title loads first */}
18 <Suspense fallback={<TitleSkeleton />}>
19 <ArticleTitle />
20 </Suspense>
21
22 {/* Then content */}
23 <Suspense fallback={<ContentSkeleton />}>
24 <ArticleContent />
25 </Suspense>
26
27 {/* Comments load last */}
28 <Suspense fallback={<CommentsSkeleton />}>
29 <Comments />
30 </Suspense>
31 </article>
32 );
33}
34
35// Inline loading indicator
36function LoadingOverlay({ children, isLoading }) {
37 return (
38 <div className="relative">
39 {children}
40 {isLoading && (
41 <div className="absolute inset-0 bg-white/50 flex items-center justify-center">
42 <Spinner />
43 </div>
44 )}
45 </div>
46 );
47}Testing Suspense#
1import { render, screen, waitFor } from '@testing-library/react';
2import { Suspense } from 'react';
3
4test('shows loading then content', async () => {
5 render(
6 <Suspense fallback={<div>Loading...</div>}>
7 <AsyncComponent />
8 </Suspense>
9 );
10
11 // Initially shows loading
12 expect(screen.getByText('Loading...')).toBeInTheDocument();
13
14 // Eventually shows content
15 await waitFor(() => {
16 expect(screen.getByText('Content')).toBeInTheDocument();
17 });
18});
19
20// Mock suspense resource
21jest.mock('./api', () => ({
22 fetchUser: jest.fn(() =>
23 Promise.resolve({ name: 'John' })
24 ),
25}));Best Practices#
Boundaries:
✓ Place boundaries around independent sections
✓ Use nested boundaries for granular loading
✓ Combine with error boundaries
✓ Keep fallbacks visually similar
Performance:
✓ Start fetching before render when possible
✓ Use transitions for navigation
✓ Defer non-critical updates
✓ Stream SSR content
UX:
✓ Use skeleton screens over spinners
✓ Show partial content early
✓ Maintain layout stability
✓ Provide retry mechanisms
Conclusion#
Suspense boundaries declaratively handle async loading states. Place them strategically for granular loading, combine with error boundaries for resilience, and use transitions for smooth navigation. In Next.js, leverage streaming SSR for optimal performance.