Back to Blog
ReactSuspenseData FetchingPerformance

React Suspense Patterns and Best Practices

Master React Suspense for data fetching and code splitting. From basic usage to nested boundaries to streaming SSR.

B
Bootspring Team
Engineering
December 26, 2021
5 min read

React Suspense enables declarative loading states. Here's how to use it effectively for data fetching and code splitting.

Basic Suspense#

1import { Suspense, lazy } from 'react'; 2 3// Code splitting with lazy 4const HeavyComponent = lazy(() => import('./HeavyComponent')); 5 6function App() { 7 return ( 8 <Suspense fallback={<LoadingSpinner />}> 9 <HeavyComponent /> 10 </Suspense> 11 ); 12} 13 14// Multiple lazy components 15const Dashboard = lazy(() => import('./Dashboard')); 16const Analytics = lazy(() => import('./Analytics')); 17const Settings = lazy(() => import('./Settings')); 18 19function App() { 20 return ( 21 <Suspense fallback={<PageSkeleton />}> 22 <Routes> 23 <Route path="/dashboard" element={<Dashboard />} /> 24 <Route path="/analytics" element={<Analytics />} /> 25 <Route path="/settings" element={<Settings />} /> 26 </Routes> 27 </Suspense> 28 ); 29}

Data Fetching with Suspense#

1// Using React Query with Suspense 2import { useSuspenseQuery } from '@tanstack/react-query'; 3 4function UserProfile({ userId }: { userId: string }) { 5 const { data: user } = useSuspenseQuery({ 6 queryKey: ['user', userId], 7 queryFn: () => fetchUser(userId), 8 }); 9 10 // No loading check needed - Suspense handles it 11 return ( 12 <div> 13 <h1>{user.name}</h1> 14 <p>{user.email}</p> 15 </div> 16 ); 17} 18 19function UserPage({ userId }: { userId: string }) { 20 return ( 21 <Suspense fallback={<ProfileSkeleton />}> 22 <UserProfile userId={userId} /> 23 </Suspense> 24 ); 25} 26 27// Multiple suspending components 28function Dashboard() { 29 return ( 30 <div className="dashboard"> 31 <Suspense fallback={<StatsSkeleton />}> 32 <Stats /> 33 </Suspense> 34 35 <Suspense fallback={<ChartSkeleton />}> 36 <RevenueChart /> 37 </Suspense> 38 39 <Suspense fallback={<TableSkeleton />}> 40 <RecentOrders /> 41 </Suspense> 42 </div> 43 ); 44}

Nested Suspense Boundaries#

1// Granular loading states 2function ProductPage({ productId }: { productId: string }) { 3 return ( 4 <div className="product-page"> 5 {/* Critical content loads first */} 6 <Suspense fallback={<ProductHeaderSkeleton />}> 7 <ProductHeader productId={productId} /> 8 9 {/* Secondary content can load independently */} 10 <div className="product-details"> 11 <Suspense fallback={<DescriptionSkeleton />}> 12 <ProductDescription productId={productId} /> 13 </Suspense> 14 15 <Suspense fallback={<ReviewsSkeleton />}> 16 <ProductReviews productId={productId} /> 17 </Suspense> 18 </div> 19 </Suspense> 20 21 {/* Recommendations load last */} 22 <Suspense fallback={<RecommendationsSkeleton />}> 23 <RelatedProducts productId={productId} /> 24 </Suspense> 25 </div> 26 ); 27} 28 29// Avoid nested loading waterfalls 30// Bad: Each component waits for parent to load 31function BadPattern() { 32 return ( 33 <Suspense fallback={<Loading />}> 34 <Parent> 35 <Suspense fallback={<Loading />}> 36 <Child /> {/* Waits for Parent to load */} 37 </Suspense> 38 </Parent> 39 </Suspense> 40 ); 41} 42 43// Good: Parallel loading with SuspenseList 44import { SuspenseList } from 'react'; 45 46function GoodPattern() { 47 return ( 48 <SuspenseList revealOrder="forwards"> 49 <Suspense fallback={<HeaderSkeleton />}> 50 <Header /> 51 </Suspense> 52 <Suspense fallback={<ContentSkeleton />}> 53 <Content /> 54 </Suspense> 55 <Suspense fallback={<FooterSkeleton />}> 56 <Footer /> 57 </Suspense> 58 </SuspenseList> 59 ); 60}

Error Boundaries with Suspense#

1import { Suspense } from 'react'; 2import { ErrorBoundary } from 'react-error-boundary'; 3 4function ErrorFallback({ error, resetErrorBoundary }) { 5 return ( 6 <div role="alert"> 7 <p>Something went wrong:</p> 8 <pre>{error.message}</pre> 9 <button onClick={resetErrorBoundary}>Try again</button> 10 </div> 11 ); 12} 13 14function DataSection() { 15 return ( 16 <ErrorBoundary FallbackComponent={ErrorFallback}> 17 <Suspense fallback={<LoadingSpinner />}> 18 <DataComponent /> 19 </Suspense> 20 </ErrorBoundary> 21 ); 22} 23 24// Retry on error 25function RetryableSection({ queryKey }) { 26 return ( 27 <ErrorBoundary 28 FallbackComponent={ErrorFallback} 29 onReset={() => { 30 queryClient.invalidateQueries({ queryKey }); 31 }} 32 resetKeys={[queryKey]} 33 > 34 <Suspense fallback={<Skeleton />}> 35 <DataComponent queryKey={queryKey} /> 36 </Suspense> 37 </ErrorBoundary> 38 ); 39}

Streaming SSR with Suspense#

1// Next.js App Router 2// app/page.tsx 3import { Suspense } from 'react'; 4 5async function SlowComponent() { 6 const data = await fetchSlowData(); // Takes 2 seconds 7 return <div>{data}</div>; 8} 9 10async function FastComponent() { 11 const data = await fetchFastData(); // Takes 100ms 12 return <div>{data}</div>; 13} 14 15export default function Page() { 16 return ( 17 <div> 18 {/* Fast content streams immediately */} 19 <Suspense fallback={<FastSkeleton />}> 20 <FastComponent /> 21 </Suspense> 22 23 {/* Slow content streams when ready */} 24 <Suspense fallback={<SlowSkeleton />}> 25 <SlowComponent /> 26 </Suspense> 27 </div> 28 ); 29} 30 31// The HTML streams progressively: 32// 1. Shell with fallbacks sent immediately 33// 2. FastComponent content injected (~100ms) 34// 3. SlowComponent content injected (~2s)

Transitions with Suspense#

1import { useTransition, useState, 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 <TabButtons 16 selectedTab={tab} 17 onSelect={selectTab} 18 isPending={isPending} 19 /> 20 21 <div className={isPending ? 'pending' : ''}> 22 <Suspense fallback={<TabSkeleton />}> 23 {tab === 'home' && <HomeTab />} 24 {tab === 'posts' && <PostsTab />} 25 {tab === 'contact' && <ContactTab />} 26 </Suspense> 27 </div> 28 </div> 29 ); 30} 31 32// Keep showing old content while loading new 33// isPending lets you show loading indicator without hiding content

Preloading Data#

1// Preload on hover/focus 2const preloadUser = (userId: string) => { 3 queryClient.prefetchQuery({ 4 queryKey: ['user', userId], 5 queryFn: () => fetchUser(userId), 6 }); 7}; 8 9function UserLink({ userId, children }) { 10 return ( 11 <Link 12 to={`/users/${userId}`} 13 onMouseEnter={() => preloadUser(userId)} 14 onFocus={() => preloadUser(userId)} 15 > 16 {children} 17 </Link> 18 ); 19} 20 21// Preload component code 22const UserProfile = lazy(() => import('./UserProfile')); 23 24function preloadUserProfile() { 25 import('./UserProfile'); 26} 27 28function UserLink({ userId }) { 29 return ( 30 <Link 31 to={`/users/${userId}`} 32 onMouseEnter={() => { 33 preloadUserProfile(); 34 preloadUser(userId); 35 }} 36 > 37 View Profile 38 </Link> 39 ); 40}

Custom Suspense-Enabled Hook#

1// Create a resource for Suspense 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 (err) => { 13 status = 'error'; 14 error = err; 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(userId)); 34 35function UserProfile() { 36 const user = userResource.read(); // Suspends if pending 37 return <div>{user.name}</div>; 38}

Best Practices#

Boundaries: ✓ Place boundaries at meaningful UI sections ✓ Use granular boundaries for independent content ✓ Combine with ErrorBoundary ✓ Consider user experience flow Performance: ✓ Preload on user intent ✓ Use streaming SSR ✓ Avoid loading waterfalls ✓ Show meaningful skeletons Patterns: ✓ Use transitions for tab switching ✓ Keep old content visible during loads ✓ Parallel data fetching ✓ Progressive enhancement

Conclusion#

React Suspense simplifies loading state management. Place boundaries strategically for optimal UX, combine with error boundaries for resilience, and use transitions to keep the UI responsive. With streaming SSR, you can deliver fast initial loads while progressively enhancing the page.

Share this article

Help spread the word about Bootspring