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