Back to Blog
ReactSuspenseLoadingAsync

React Suspense Boundaries

Master Suspense boundaries in React. From loading states to error handling to streaming SSR.

B
Bootspring Team
Engineering
March 5, 2021
6 min read

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 first

Suspense 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 fallbacks

Transitions 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.

Share this article

Help spread the word about Bootspring