Back to Blog
ReactSuspenseAsyncLoading

React Suspense Guide

Master React Suspense for handling async operations with loading states and error boundaries.

B
Bootspring Team
Engineering
April 3, 2020
6 min read

React Suspense lets you declaratively handle loading states for async operations. Here's how to use it.

Basic Suspense#

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

Nested Suspense#

1// Different loading states for different sections 2function App() { 3 return ( 4 <Suspense fallback={<PageSkeleton />}> 5 <Header /> 6 <main> 7 <Suspense fallback={<SidebarSkeleton />}> 8 <Sidebar /> 9 </Suspense> 10 <Suspense fallback={<ContentSkeleton />}> 11 <Content /> 12 </Suspense> 13 </main> 14 </Suspense> 15 ); 16} 17 18// Cascading loading 19function Dashboard() { 20 return ( 21 <div> 22 <h1>Dashboard</h1> 23 <Suspense fallback={<ChartSkeleton />}> 24 <Charts /> 25 <Suspense fallback={<TableSkeleton />}> 26 <DataTable /> 27 </Suspense> 28 </Suspense> 29 </div> 30 ); 31}

Loading Fallbacks#

1// Simple spinner 2function LoadingSpinner() { 3 return ( 4 <div className="spinner-container"> 5 <div className="spinner" /> 6 </div> 7 ); 8} 9 10// Skeleton loader 11function CardSkeleton() { 12 return ( 13 <div className="card skeleton"> 14 <div className="skeleton-image" /> 15 <div className="skeleton-title" /> 16 <div className="skeleton-text" /> 17 <div className="skeleton-text short" /> 18 </div> 19 ); 20} 21 22// Progress indicator 23function LoadingProgress({ message = 'Loading...' }) { 24 return ( 25 <div className="loading-progress"> 26 <div className="progress-bar"> 27 <div className="progress-fill" /> 28 </div> 29 <p>{message}</p> 30 </div> 31 ); 32} 33 34// Contextual loading 35function TableLoadingState() { 36 return ( 37 <table className="loading-table"> 38 <thead> 39 <tr> 40 <th><div className="skeleton-cell" /></th> 41 <th><div className="skeleton-cell" /></th> 42 <th><div className="skeleton-cell" /></th> 43 </tr> 44 </thead> 45 <tbody> 46 {Array.from({ length: 5 }).map((_, i) => ( 47 <tr key={i}> 48 <td><div className="skeleton-cell" /></td> 49 <td><div className="skeleton-cell" /></td> 50 <td><div className="skeleton-cell" /></td> 51 </tr> 52 ))} 53 </tbody> 54 </table> 55 ); 56}

Error Boundaries with Suspense#

1import { Component, Suspense } 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 componentDidCatch(error, errorInfo) { 11 console.error('Error caught:', error, errorInfo); 12 } 13 14 render() { 15 if (this.state.hasError) { 16 return ( 17 <div className="error-container"> 18 <h2>Something went wrong</h2> 19 <p>{this.state.error?.message}</p> 20 <button onClick={() => this.setState({ hasError: false })}> 21 Try again 22 </button> 23 </div> 24 ); 25 } 26 27 return this.props.children; 28 } 29} 30 31// Usage 32function App() { 33 return ( 34 <ErrorBoundary> 35 <Suspense fallback={<Loading />}> 36 <AsyncComponent /> 37 </Suspense> 38 </ErrorBoundary> 39 ); 40} 41 42// Reusable wrapper 43function AsyncBoundary({ children, fallback, errorFallback }) { 44 return ( 45 <ErrorBoundary fallback={errorFallback}> 46 <Suspense fallback={fallback}> 47 {children} 48 </Suspense> 49 </ErrorBoundary> 50 ); 51}

Data Fetching with Suspense#

1// Simple cache for demonstration 2const cache = new Map(); 3 4function fetchData(url) { 5 if (!cache.has(url)) { 6 cache.set(url, fetchWithSuspense(url)); 7 } 8 return cache.get(url); 9} 10 11function fetchWithSuspense(url) { 12 let status = 'pending'; 13 let result; 14 15 const promise = fetch(url) 16 .then(res => res.json()) 17 .then(data => { 18 status = 'success'; 19 result = data; 20 }) 21 .catch(error => { 22 status = 'error'; 23 result = error; 24 }); 25 26 return { 27 read() { 28 if (status === 'pending') throw promise; 29 if (status === 'error') throw result; 30 return result; 31 } 32 }; 33} 34 35// Component that reads data 36function UserProfile({ userId }) { 37 const data = fetchData(`/api/users/${userId}`); 38 const user = data.read(); // Suspends if pending 39 40 return ( 41 <div> 42 <h2>{user.name}</h2> 43 <p>{user.email}</p> 44 </div> 45 ); 46} 47 48// Usage 49function App() { 50 return ( 51 <Suspense fallback={<ProfileSkeleton />}> 52 <UserProfile userId={1} /> 53 </Suspense> 54 ); 55}

SuspenseList (Experimental)#

1import { Suspense, SuspenseList } from 'react'; 2 3// Control reveal order of multiple suspense boundaries 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 order, bottom to top 23// - "together": reveal all at once when all ready 24 25// tail options: 26// - "collapsed": show one fallback at a time 27// - "hidden": show no fallbacks for unrevealed items

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) { 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('contact')}>Contact</button> 19 </nav> 20 <Suspense fallback={<TabSkeleton />}> 21 <TabContent tab={tab} /> 22 </Suspense> 23 </div> 24 ); 25} 26 27// Keep showing old content while loading new 28function TabContent({ tab }) { 29 const data = fetchTabData(tab); 30 const content = data.read(); 31 32 return <div>{content}</div>; 33}

useDeferredValue#

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

Route-based Code Splitting#

1import { Suspense, lazy } from 'react'; 2import { BrowserRouter, Routes, Route } from 'react-router-dom'; 3 4// Lazy load routes 5const Home = lazy(() => import('./pages/Home')); 6const About = lazy(() => import('./pages/About')); 7const Products = lazy(() => import('./pages/Products')); 8const ProductDetail = lazy(() => import('./pages/ProductDetail')); 9 10function App() { 11 return ( 12 <BrowserRouter> 13 <Layout> 14 <Suspense fallback={<PageLoader />}> 15 <Routes> 16 <Route path="/" element={<Home />} /> 17 <Route path="/about" element={<About />} /> 18 <Route path="/products" element={<Products />} /> 19 <Route path="/products/:id" element={<ProductDetail />} /> 20 </Routes> 21 </Suspense> 22 </Layout> 23 </BrowserRouter> 24 ); 25} 26 27// Preload on hover 28const ProductsPage = lazy(() => import('./pages/Products')); 29 30function preloadProducts() { 31 import('./pages/Products'); 32} 33 34function Nav() { 35 return ( 36 <nav> 37 <Link to="/">Home</Link> 38 <Link 39 to="/products" 40 onMouseEnter={preloadProducts} 41 onFocus={preloadProducts} 42 > 43 Products 44 </Link> 45 </nav> 46 ); 47}

Best Practices#

Fallback Design: ✓ Match fallback to content shape ✓ Use skeleton screens for better UX ✓ Keep fallbacks lightweight ✓ Avoid layout shifts Boundaries: ✓ Place near async content ✓ Use nested Suspense for granularity ✓ Combine with Error Boundaries ✓ Consider user experience flow Performance: ✓ Preload critical paths ✓ Use transitions for navigation ✓ Avoid waterfall loading ✓ Cache fetched data Avoid: ✗ Suspense boundary too high ✗ Missing error handling ✗ Flash of loading states ✗ Over-splitting code

Conclusion#

React Suspense provides a declarative way to handle loading states. Use it with lazy loading for code splitting, combine with Error Boundaries for robust error handling, and leverage transitions for smooth navigation. Design fallbacks that match your content structure to minimize layout shifts.

Share this article

Help spread the word about Bootspring