Back to Blog
ReactSuspenseLoadingPerformance

React Suspense Boundaries Guide

Master React Suspense for declarative loading states and code splitting.

B
Bootspring Team
Engineering
June 21, 2018
7 min read

React Suspense lets you declaratively specify loading states for components that are waiting for data or code to load.

Basic Usage#

1import { Suspense, lazy } from 'react'; 2 3// Lazy load a component 4const LazyComponent = lazy(() => import('./HeavyComponent')); 5 6function App() { 7 return ( 8 <Suspense fallback={<div>Loading...</div>}> 9 <LazyComponent /> 10 </Suspense> 11 ); 12}

Code Splitting with lazy()#

1import { Suspense, lazy } from 'react'; 2 3// Split by routes 4const Home = lazy(() => import('./pages/Home')); 5const About = lazy(() => import('./pages/About')); 6const Dashboard = lazy(() => import('./pages/Dashboard')); 7 8function App() { 9 return ( 10 <Suspense fallback={<PageLoader />}> 11 <Routes> 12 <Route path="/" element={<Home />} /> 13 <Route path="/about" element={<About />} /> 14 <Route path="/dashboard" element={<Dashboard />} /> 15 </Routes> 16 </Suspense> 17 ); 18} 19 20// Named exports 21const MyComponent = lazy(() => 22 import('./components').then(module => ({ 23 default: module.MyComponent 24 })) 25); 26 27// With preload 28const HeavyChart = lazy(() => import('./HeavyChart')); 29 30// Preload on hover 31function ChartButton() { 32 const handleMouseEnter = () => { 33 import('./HeavyChart'); // Start loading 34 }; 35 36 return ( 37 <button onMouseEnter={handleMouseEnter}> 38 Show Chart 39 </button> 40 ); 41}

Nested Suspense Boundaries#

1import { Suspense, lazy } from 'react'; 2 3const Header = lazy(() => import('./Header')); 4const Sidebar = lazy(() => import('./Sidebar')); 5const MainContent = lazy(() => import('./MainContent')); 6const Comments = lazy(() => import('./Comments')); 7 8function App() { 9 return ( 10 <div className="app"> 11 {/* Outer boundary for layout */} 12 <Suspense fallback={<LayoutSkeleton />}> 13 <Header /> 14 15 <div className="body"> 16 <Sidebar /> 17 18 {/* Inner boundary for content */} 19 <Suspense fallback={<ContentSkeleton />}> 20 <MainContent /> 21 22 {/* Innermost for comments */} 23 <Suspense fallback={<CommentsSkeleton />}> 24 <Comments /> 25 </Suspense> 26 </Suspense> 27 </div> 28 </Suspense> 29 </div> 30 ); 31}

Data Fetching with Suspense#

1// With React Query / TanStack Query 2import { useSuspenseQuery } from '@tanstack/react-query'; 3 4function UserProfile({ userId }) { 5 // This suspends until data is ready 6 const { data: user } = useSuspenseQuery({ 7 queryKey: ['user', userId], 8 queryFn: () => fetchUser(userId) 9 }); 10 11 return ( 12 <div> 13 <h1>{user.name}</h1> 14 <p>{user.email}</p> 15 </div> 16 ); 17} 18 19function App() { 20 return ( 21 <Suspense fallback={<ProfileSkeleton />}> 22 <UserProfile userId={123} /> 23 </Suspense> 24 ); 25} 26 27// Multiple suspending components 28function Dashboard() { 29 return ( 30 <Suspense fallback={<DashboardSkeleton />}> 31 <UserProfile /> 32 <UserStats /> 33 <RecentActivity /> 34 </Suspense> 35 ); 36}

Suspense with Error Boundaries#

1import { Suspense, Component } from 'react'; 2 3class ErrorBoundary extends Component { 4 state = { hasError: false, error: null }; 5 6 static getDerivedStateFromError(error) { 7 return { hasError: true, error }; 8 } 9 10 render() { 11 if (this.state.hasError) { 12 return this.props.fallback || <div>Something went wrong</div>; 13 } 14 return this.props.children; 15 } 16} 17 18// Combine Error Boundary and Suspense 19function AsyncBoundary({ children, errorFallback, loadingFallback }) { 20 return ( 21 <ErrorBoundary fallback={errorFallback}> 22 <Suspense fallback={loadingFallback}> 23 {children} 24 </Suspense> 25 </ErrorBoundary> 26 ); 27} 28 29// Usage 30function App() { 31 return ( 32 <AsyncBoundary 33 loadingFallback={<Spinner />} 34 errorFallback={<ErrorMessage />} 35 > 36 <DataComponent /> 37 </AsyncBoundary> 38 ); 39}

Loading States and Skeletons#

1// Skeleton components 2function ProfileSkeleton() { 3 return ( 4 <div className="profile-skeleton"> 5 <div className="skeleton avatar" /> 6 <div className="skeleton name" /> 7 <div className="skeleton bio" /> 8 </div> 9 ); 10} 11 12function CardSkeleton() { 13 return ( 14 <div className="card-skeleton"> 15 <div className="skeleton image" /> 16 <div className="skeleton title" /> 17 <div className="skeleton text" /> 18 <div className="skeleton text short" /> 19 </div> 20 ); 21} 22 23// Match skeleton to component structure 24function PostList() { 25 const { data: posts } = useSuspenseQuery({ 26 queryKey: ['posts'], 27 queryFn: fetchPosts 28 }); 29 30 return ( 31 <div className="post-list"> 32 {posts.map(post => ( 33 <PostCard key={post.id} post={post} /> 34 ))} 35 </div> 36 ); 37} 38 39function PostListSkeleton() { 40 return ( 41 <div className="post-list"> 42 {[1, 2, 3].map(i => ( 43 <CardSkeleton key={i} /> 44 ))} 45 </div> 46 ); 47} 48 49// In parent 50<Suspense fallback={<PostListSkeleton />}> 51 <PostList /> 52</Suspense>

SuspenseList (Experimental)#

1import { Suspense, SuspenseList } from 'react'; 2 3// Control reveal order 4function Feed() { 5 return ( 6 <SuspenseList revealOrder="forwards" tail="collapsed"> 7 <Suspense fallback={<PostSkeleton />}> 8 <Post id={1} /> 9 </Suspense> 10 <Suspense fallback={<PostSkeleton />}> 11 <Post id={2} /> 12 </Suspense> 13 <Suspense fallback={<PostSkeleton />}> 14 <Post id={3} /> 15 </Suspense> 16 </SuspenseList> 17 ); 18} 19 20// revealOrder options: 21// - 'forwards': reveal in order (top to bottom) 22// - 'backwards': reveal in reverse order 23// - 'together': reveal all at once when ready 24 25// tail options: 26// - 'collapsed': only show one fallback at a time 27// - 'hidden': don't show fallbacks for items not yet revealed

useTransition for Non-Blocking Updates#

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) { 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('posts')}>Posts</button> 18 <button onClick={() => selectTab('settings')}>Settings</button> 19 </nav> 20 21 <Suspense fallback={<TabSkeleton />}> 22 {tab === 'home' && <Home />} 23 {tab === 'posts' && <Posts />} 24 {tab === 'settings' && <Settings />} 25 </Suspense> 26 </div> 27 ); 28}

useDeferredValue#

1import { useState, useDeferredValue, Suspense, memo } from 'react'; 2 3function SearchResults({ query }) { 4 const { data } = useSuspenseQuery({ 5 queryKey: ['search', query], 6 queryFn: () => search(query) 7 }); 8 9 return ( 10 <ul> 11 {data.map(item => ( 12 <li key={item.id}>{item.name}</li> 13 ))} 14 </ul> 15 ); 16} 17 18const MemoizedResults = memo(SearchResults); 19 20function SearchPage() { 21 const [query, setQuery] = useState(''); 22 const deferredQuery = useDeferredValue(query); 23 24 const isStale = query !== deferredQuery; 25 26 return ( 27 <div> 28 <input 29 value={query} 30 onChange={e => setQuery(e.target.value)} 31 placeholder="Search..." 32 /> 33 34 <Suspense fallback={<ResultsSkeleton />}> 35 <div style={{ opacity: isStale ? 0.7 : 1 }}> 36 <MemoizedResults query={deferredQuery} /> 37 </div> 38 </Suspense> 39 </div> 40 ); 41}

Server Components with Suspense#

1// Server Component (Next.js App Router) 2async function UserProfile({ userId }) { 3 const user = await fetchUser(userId); 4 5 return ( 6 <div> 7 <h1>{user.name}</h1> 8 <p>{user.email}</p> 9 </div> 10 ); 11} 12 13// Page with Suspense 14export default function Page({ params }) { 15 return ( 16 <div> 17 <h1>Dashboard</h1> 18 19 <Suspense fallback={<ProfileSkeleton />}> 20 <UserProfile userId={params.id} /> 21 </Suspense> 22 23 <Suspense fallback={<StatsSkeleton />}> 24 <UserStats userId={params.id} /> 25 </Suspense> 26 </div> 27 ); 28} 29 30// Streaming with loading.js (Next.js) 31// app/dashboard/loading.js 32export default function Loading() { 33 return <DashboardSkeleton />; 34}

Custom Suspense-Compatible Resources#

1// Create suspense-compatible resource 2function createResource(promise) { 3 let status = 'pending'; 4 let result; 5 6 const suspender = promise.then( 7 data => { 8 status = 'success'; 9 result = data; 10 }, 11 error => { 12 status = 'error'; 13 result = error; 14 } 15 ); 16 17 return { 18 read() { 19 if (status === 'pending') throw suspender; 20 if (status === 'error') throw result; 21 return result; 22 } 23 }; 24} 25 26// Usage 27const userResource = createResource(fetchUser(1)); 28 29function UserProfile() { 30 const user = userResource.read(); // Suspends if pending 31 32 return <div>{user.name}</div>; 33} 34 35// Wrapper component 36function App() { 37 return ( 38 <Suspense fallback={<Loading />}> 39 <UserProfile /> 40 </Suspense> 41 ); 42}

Loading Priority Patterns#

1// Critical content loads first 2function ProductPage({ productId }) { 3 return ( 4 <div> 5 {/* Critical - no suspense, or high priority */} 6 <ProductHeader productId={productId} /> 7 8 {/* Important - medium priority */} 9 <Suspense fallback={<PriceSkeleton />}> 10 <ProductPrice productId={productId} /> 11 </Suspense> 12 13 {/* Can wait - low priority */} 14 <Suspense fallback={<ReviewsSkeleton />}> 15 <ProductReviews productId={productId} /> 16 </Suspense> 17 18 {/* Lazy load when visible */} 19 <LazySection> 20 <Suspense fallback={<RecommendationsSkeleton />}> 21 <Recommendations productId={productId} /> 22 </Suspense> 23 </LazySection> 24 </div> 25 ); 26} 27 28// Intersection Observer for lazy loading 29function LazySection({ children }) { 30 const [isVisible, setIsVisible] = useState(false); 31 const ref = useRef(); 32 33 useEffect(() => { 34 const observer = new IntersectionObserver( 35 ([entry]) => { 36 if (entry.isIntersecting) { 37 setIsVisible(true); 38 observer.disconnect(); 39 } 40 }, 41 { rootMargin: '100px' } 42 ); 43 44 if (ref.current) observer.observe(ref.current); 45 return () => observer.disconnect(); 46 }, []); 47 48 return <div ref={ref}>{isVisible ? children : null}</div>; 49}

Best Practices#

Boundary Placement: ✓ Place at route level for pages ✓ Use nested boundaries for sections ✓ Match skeletons to component structure ✓ Consider user experience Loading States: ✓ Use meaningful skeletons ✓ Avoid layout shifts ✓ Show progress when possible ✓ Keep fallbacks lightweight Performance: ✓ Preload critical resources ✓ Use useTransition for navigation ✓ Defer non-critical updates ✓ Code split by routes Avoid: ✗ Too many suspense boundaries ✗ Flashing loading states ✗ Blocking critical content ✗ Forgetting error boundaries

Conclusion#

React Suspense provides declarative loading states for async operations. Use it with lazy() for code splitting, with data fetching libraries for loading states, and with useTransition for smooth transitions. Place Suspense boundaries strategically to control loading experiences. Combine with Error Boundaries for comprehensive async handling. Match skeleton components to actual content structure to prevent layout shifts and provide better user experience.

Share this article

Help spread the word about Bootspring