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