React Suspense
1. What is React Suspense?
React Suspense is a built-in React component that lets you pause (or “suspend”) the rendering of a part of your component tree while it is waiting for something asynchronous to complete, such as loading a JavaScript chunk or fetching data from a server, and display a fallback UI (like a spinner or skeleton screen) in the meantime.
Official Definition (React Docs):
“Suspense lets you display a fallback until its children have finished loading.”
Think of it like a placeholder in a magazine: the layout reserves space with a “Loading…” box until the actual article content arrives. Once content is ready, it snaps into place, without any layout jumps or manual state management.
Simplest Example
import React, { Suspense } from 'react';
// Lazy-load a heavy component
const HeavyChart = React.lazy(() => import('./HeavyChart'));
function App() {
return (
<Suspense fallback={<p>Loading chart...</p>}>
<HeavyChart />
</Suspense>
);
}
In the example above:
React.lazy()tells React to loadHeavyChartonly when it is first needed.<Suspense fallback={...}>wraps the lazy component and shows “Loading chart…” until the JS bundle forHeavyChartfinishes downloading.- Once loaded, React automatically replaces the fallback with the real component.
2. Why Was Suspense Introduced?
Before Suspense existed, handling async operations in React required a lot of repetitive, manual boilerplate:
Old Way (Without Suspense)
function UserProfile({ userId }) {
const [user, setUser] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
React.useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => { setUser(data); setLoading(false); })
.catch(err => { setError(err); setLoading(false); });
}, [userId]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <h1>{user.name}</h1>;
}
Problems with this approach:
- Every component that fetches data must repeat the same loading/error state pattern.
- Loading states are scattered across the component tree — hard to coordinate.
- Race conditions can occur (e.g., stale responses arriving after a newer fetch).
- No unified way to coordinate multiple concurrent data loads.
- Code becomes cluttered with UI logic mixed into data logic.
New Way (With Suspense)
// The component just reads data — no loading/error state needed here
function UserProfile({ userId }) {
const user = resource.read(userId); // throws a Promise if not ready
return <h1>{user.name}</h1>;
}
// Loading UI is declared ONCE at the boundary
function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<UserProfile userId={1} />
</Suspense>
);
}
Benefits of Suspense:
- Loading state is centralized at the Suspense boundary, not scattered in every component.
- Components stay clean, they only describe what to render, not when it’s ready.
- Works seamlessly with React’s Concurrent Mode for smoother UX.
- Enables coordinated loading of multiple resources.
3. How Does Suspense Work Internally?
Understanding what happens under the hood will make you a much more confident user of Suspense.
The “Throw a Promise” Mechanism
React Suspense works through a special protocol:
- A child component that is not yet ready throws a Promise instead of returning JSX.
- React catches that Promise at the nearest
<Suspense>boundary up the tree. - React renders the
fallbackprop in place of the suspended subtree. - React waits for the Promise to resolve.
- Once the Promise resolves, React re-renders the suspended subtree, this time the component returns JSX normally (the data/resource is ready).
- The fallback is replaced with the actual content.
// Conceptual illustration — simplified, not production code
function createResource(fetchFn) {
let status = 'pending';
let result;
const promise = fetchFn().then(
data => { status = 'success'; result = data; },
error => { status = 'error'; result = error; }
);
return {
read() {
if (status === 'pending') throw promise; // <-- This suspends React
if (status === 'error') throw result; // <-- This triggers ErrorBoundary
if (status === 'success') return result; // <-- Normal render
}
};
}
This “throw a Promise” pattern is the core contract. React itself does not care how the Promise is thrown — it just needs to catch one from a child during rendering.
Key Concepts
- Suspense Boundary: Any
<Suspense>component in the tree. It acts as a catch-point for thrown Promises. - Suspended Component: A component that throws a Promise during render.
- Fallback UI: What is shown while children are suspended.
- Re-render: React retries rendering the suspended subtree after the Promise resolves.
4. Types / Use Cases of React Suspense
Suspense is not a single-purpose tool. It has several important use cases, each explained in depth below.
Type 1: Lazy Loading Components (Code Splitting)
This is the most widely used and stable form of Suspense, available since React 16.6. It works with React.lazy() to split your JavaScript bundle so that heavy components are only downloaded when they are actually needed.
What is Code Splitting?
By default, tools like Webpack or Vite bundle all your JavaScript into one file. For large apps, this means users download code they may never need. Code splitting breaks the bundle into smaller chunks that load on demand.
Syntax
const ComponentName = React.lazy(() => import('./path/to/Component'));
React.lazy()accepts a function that returns a dynamicimport().- The imported module must have a default export that is a React component.
- The component must be wrapped in
<Suspense>.
Full Example: Route-Based Code Splitting
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// These bundles load only when the user visits the route
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function LoadingSpinner() {
return <p>Loading page...</p>;
}
function App() {
return (
<BrowserRouter>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
export default App;
Component-Level Lazy Loading
import React, { Suspense, lazy, useState } from 'react';
const HeavyModal = lazy(() => import('./HeavyModal'));
function Page() {
const [showModal, setShowModal] = useState(false);
return (
<div>
<button onClick={() => setShowModal(true)}>Open Modal</button>
{showModal && (
<Suspense fallback={<p>Loading modal...</p>}>
<HeavyModal onClose={() => setShowModal(false)} />
</Suspense>
)}
</div>
);
}
What happens here step by step:
- On first click, React sees
HeavyModalis lazy and has not been loaded yet. - React.lazy fires the dynamic
import(), which returns a Promise. - React catches the Promise via the Suspense boundary and renders “Loading modal…”.
- Webpack/Vite downloads the
HeavyModalJS chunk over the network. - Once downloaded, React re-renders and shows the actual modal.
Type 2: Data Fetching with Suspense
This is the more powerful (and more complex) use case. Instead of lazy-loading code, you are lazy-loading data. This requires either:
- A Suspense-compatible data library (e.g., React Query / TanStack Query, SWR, Relay), or
- Writing your own resource wrapper (for learning/advanced use).
Using React Query (Recommended Production Approach)
// 1. Install: npm install @tanstack/react-query
// 2. Configure QueryClient with suspense: true
import { QueryClient, QueryClientProvider, useSuspenseQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
const queryClient = new QueryClient();
// This component just reads data — no loading/error state
function UserCard({ userId }) {
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
.then(res => res.json()),
});
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
<p>{user.company.name}</p>
</div>
);
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<Suspense fallback={<p>Fetching user data...</p>}>
<UserCard userId={1} />
</Suspense>
</QueryClientProvider>
);
}
Manual Resource Pattern (Educational — Understand the Internals)
// Utility: wraps any Promise into a Suspense-compatible resource
function wrapPromise(promise) {
let status = 'pending';
let result;
const suspender = promise.then(
data => { status = 'success'; result = data; },
error => { status = 'error'; result = error; }
);
return {
read() {
if (status === 'pending') throw suspender;
if (status === 'error') throw result;
return result;
}
};
}
// Create the resource OUTSIDE the component (so it doesn't restart on re-render)
const userResource = wrapPromise(
fetch('https://jsonplaceholder.typicode.com/users/1').then(r => r.json())
);
function UserProfile() {
// .read() either returns data OR throws a Promise
const user = userResource.read();
return <h1>Hello, {user.name}!</h1>;
}
function App() {
return (
<Suspense fallback={<p>Loading user...</p>}>
<UserProfile />
</Suspense>
);
}
Important: The resource must be created outside the component or in a stable reference. Creating it inside the component would cause an infinite loop because each re-render would create a new fetch request.
Type 3: Server-Side Rendering (SSR) with Suspense
React 18 introduced Streaming SSR — where the server can stream HTML to the browser piece by piece, and each piece streams as soon as it is ready. Suspense is the mechanism that controls which parts stream when.
Old SSR Problem
In old React SSR, the server had to fetch ALL data, render ALL HTML, and only then send the full page to the browser. If one data source was slow (say, a recommendation engine taking 3 seconds), the entire page was blocked for 3 seconds.
New Streaming SSR with Suspense
With React 18 + Suspense, the server sends the shell of the page immediately, and fills in each Suspense boundary as its data resolves — independently and concurrently.
// server.js (Node.js with React 18)
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';
function handleRequest(req, res) {
const { pipe } = renderToPipeableStream(<App />, {
onShellReady() {
// Send the initial shell (header, nav, etc.) immediately
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
});
}
// App.jsx — On the server, Suspense controls streaming
import { Suspense } from 'react';
import Sidebar from './Sidebar';
import SlowRecommendations from './SlowRecommendations';
function App() {
return (
<html>
<body>
<Sidebar /> {/* Renders and streams immediately */}
<Suspense fallback={<p>Loading recommendations...</p>}>
{/* Streams later when its data is ready */}
<SlowRecommendations />
</Suspense>
</body>
</html>
);
}
Result: The user sees a complete page instantly (with the fallback placeholder), and the recommendations pop in a few seconds later — without a full page reload.
Next.js and Suspense (Production SSR)
// In Next.js App Router — async Server Components integrate with Suspense natively
import { Suspense } from 'react';
import ProductList from './ProductList'; // async server component
export default function Page() {
return (
<main>
<h1>Our Store</h1>
<Suspense fallback={<p>Loading products...</p>}>
<ProductList />
</Suspense>
</main>
);
}
// ProductList.jsx — async server component
async function ProductList() {
const products = await fetch('https://api.example.com/products').then(r => r.json());
return (
<ul>
{products.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
Type 4: Suspense with Concurrent Features (useTransition & useDeferredValue)
React 18’s Concurrent Mode gives you two hooks that work hand-in-hand with Suspense:
- useTransition — lets you mark a state update as “non-urgent” so React can keep showing the old UI while preparing the new one.
- useDeferredValue — lets you defer updating a value so the UI doesn’t freeze during heavy re-renders.
useTransition Example — Tab Switching Without Flicker
import { useState, useTransition, Suspense, lazy } from 'react';
const PhotosTab = lazy(() => import('./PhotosTab'));
const CommentsTab = lazy(() => import('./CommentsTab'));
function ProfilePage() {
const [tab, setTab] = useState('photos');
const [isPending, startTransition] = useTransition();
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab); // This update is marked "non-urgent"
});
}
return (
<div>
<button onClick={() => selectTab('photos')}>Photos</button>
<button onClick={() => selectTab('comments')}>Comments</button>
{isPending && <p>Switching tab...</p>}
<Suspense fallback={<p>Loading tab content...</p>}>
{tab === 'photos' && <PhotosTab />}
{tab === 'comments' && <CommentsTab />}
</Suspense>
</div>
);
}
Without useTransition: clicking the tab immediately shows the fallback (“Loading tab content…”), which can feel jarring.
With useTransition: React stays on the old tab while loading the new one in the background, only switching when the new content is ready. Much smoother!
useDeferredValue Example: Avoid Re-render Freeze on Input
import { useState, useDeferredValue, Suspense } from 'react';
import SearchResults from './SearchResults'; // Suspense-compatible component
function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// deferredQuery updates slightly later than query
// so the input stays responsive even if SearchResults is slow
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
<Suspense fallback={<p>Searching...</p>}>
<SearchResults query={deferredQuery} />
</Suspense>
</div>
);
}
Type 5: Nested Suspense Boundaries
You can nest multiple <Suspense> boundaries at different levels of your component tree to give you fine-grained control over which parts of the UI show loading states independently.
function Dashboard() {
return (
<div>
{/* Header loads instantly — no Suspense needed */}
<Header />
{/* Sidebar loads independently */}
<Suspense fallback={<p>Loading sidebar...</p>}>
<Sidebar />
</Suspense>
{/* Main content area has its own loading state */}
<Suspense fallback={<p>Loading main content...</p>}>
<MainContent />
{/* Widget inside main content has its OWN nested boundary */}
<Suspense fallback={<p>Loading analytics widget...</p>}>
<AnalyticsWidget />
</Suspense>
</Suspense>
</div>
);
}
How React picks which boundary to use:
When a component suspends, React walks up the component tree and stops at the nearest <Suspense> ancestor. That boundary’s fallback is shown, not any outer ones — unless the nearest boundary is itself inside a suspended subtree.
5. The fallback Prop Explained
The fallback prop is the UI that Suspense renders while its children are suspended (i.e., loading).
Rules and Behavior
- The fallback can be any valid React node: a string, JSX, a component, null, etc.
- It is shown only while at least one child is suspended.
- As soon as all children are ready, the fallback is replaced with the real content.
- If you pass
fallback={null}, nothing is shown during loading (invisible loading state). - The fallback itself must not suspend — it is always rendered synchronously.
Fallback Examples
// Simple text
<Suspense fallback="Loading...">
<MyComponent />
</Suspense>
// JSX element
<Suspense fallback={<p>Please wait while we load your data.</p>}>
<MyComponent />
</Suspense>
// A spinner component
<Suspense fallback={<Spinner size="large" />}>
<MyComponent />
</Suspense>
// A skeleton screen (recommended for better UX)
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile />
</Suspense>
// No visible fallback (silent loading)
<Suspense fallback={null}>
<MyComponent />
</Suspense>
Skeleton Screen Fallback Example
function ProfileSkeleton() {
return (
<div aria-busy="true" aria-label="Loading profile">
<div style={{ width: 80, height: 80, background: '#ddd', borderRadius: '50%' }} />
<div style={{ width: 200, height: 16, background: '#ddd', marginTop: 12 }} />
<div style={{ width: 140, height: 14, background: '#eee', marginTop: 8 }} />
</div>
);
}
// Usage
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile />
</Suspense>
6. Error Boundaries with Suspense
Suspense handles the loading state. But what happens if the async operation fails (network error, 404, etc.)? The thrown error will bubble up to the nearest Error Boundary.
Error Boundaries are class components that implement componentDidCatch and/or getDerivedStateFromError. They catch errors thrown during rendering — including errors thrown by suspended components.
Always Pair Suspense with an Error Boundary
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
// Log to an error reporting service like Sentry
console.error('Caught error:', error, info);
}
render() {
if (this.state.hasError) {
return (
<div>
<h2>Something went wrong.</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Retry
</button>
</div>
);
}
return this.props.children;
}
}
// Wrap Suspense inside ErrorBoundary
function App() {
return (
<ErrorBoundary>
<Suspense fallback={<p>Loading...</p>}>
<DataComponent />
</Suspense>
</ErrorBoundary>
);
}
Using react-error-boundary Package (Recommended Shortcut)
// npm install react-error-boundary
import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div>
<p>Error: {error.message}</p>
<button onClick={resetErrorBoundary}>Try Again</button>
</div>
);
}
function App() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<p>Loading...</p>}>
<DataComponent />
</Suspense>
</ErrorBoundary>
);
}
7. SuspenseList (Experimental)
Note: SuspenseList is an experimental API that may change or be removed. It is not yet in stable React.
SuspenseList lets you coordinate the order in which multiple Suspense boundaries reveal their content. Without it, multiple Suspense children may appear in any order as they resolve (whichever finishes first shows first), which can cause layout jumps.
import { SuspenseList, Suspense, lazy } from 'react';
const Article = lazy(() => import('./Article'));
const Sidebar = lazy(() => import('./Sidebar'));
const Comments = lazy(() => import('./Comments'));
function Page() {
return (
<SuspenseList revealOrder="forwards" tail="collapsed">
<Suspense fallback={<p>Loading article...</p>}>
<Article />
</Suspense>
<Suspense fallback={<p>Loading sidebar...</p>}>
<Sidebar />
</Suspense>
<Suspense fallback={<p>Loading comments...</p>}>
<Comments />
</Suspense>
</SuspenseList>
);
}
SuspenseList Props
revealOrder- Controls the order in which children reveal:
"forwards"— reveal from first to last, even if a later one finishes first."backwards"— reveal from last to first."together"— reveal all at once only when ALL are ready.
tail- Controls how pending items are shown:
"collapsed"— only show the fallback for the next pending item."hidden"— don’t show any fallback for pending items.
8. Real-World Full Example
Below is a complete, self-contained React app that combines lazy loading, Suspense data fetching with a manual resource, an Error Boundary, and nested Suspense boundaries.
import React, { Suspense, lazy, useState } from 'react';
// ---- Error Boundary ----
class ErrorBoundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(e) { return { error: e }; }
render() {
if (this.state.error)
return (
<div>
<strong>Error:</strong> {this.state.error.message}
<br />
<button onClick={() => this.setState({ error: null })}>Retry</button>
</div>
);
return this.props.children;
}
}
// ---- Resource Wrapper ----
function createResource(fetchFn) {
let status = 'pending', result;
const suspender = fetchFn().then(
data => { status = 'success'; result = data; },
error => { status = 'error'; result = error; }
);
return { read() {
if (status === 'pending') throw suspender;
if (status === 'error') throw result;
return result;
}};
}
// ---- Lazy Components ----
const PostList = lazy(() => import('./PostList'));
// ---- Data Resources (created once, outside components) ----
const userResource = createResource(() =>
fetch('https://jsonplaceholder.typicode.com/users/1').then(r => r.json())
);
const postsResource = createResource(() =>
fetch('https://jsonplaceholder.typicode.com/posts?userId=1&_limit=3').then(r => r.json())
);
// ---- Components that read resources ----
function UserBio() {
const user = userResource.read();
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
<p>{user.company.name}</p>
</div>
);
}
function RecentPosts() {
const posts = postsResource.read();
return (
<ul>
{posts.map(p => (
<li key={p.id}><strong>{p.title}</strong></li>
))}
</ul>
);
}
// ---- App ----
export default function App() {
const [showPosts, setShowPosts] = useState(false);
return (
<ErrorBoundary>
<h1>User Dashboard</h1>
{/* User bio suspends independently */}
<Suspense fallback={<p>Loading user bio...</p>}>
<UserBio />
</Suspense>
<button onClick={() => setShowPosts(true)}>Load Posts</button>
{showPosts && (
<Suspense fallback={<p>Loading posts...</p>}>
<RecentPosts />
</Suspense>
)}
</ErrorBoundary>
);
}
9. Best Practices
- Always wrap Suspense with an Error Boundary.
If the async operation can fail (network errors, bad API responses), you need an Error Boundary to gracefully handle the thrown error. Without it, an uncaught error will crash the entire component tree. - Use skeleton screens over spinners for better UX.
Skeleton screens (placeholder shapes that mimic the layout of the actual content) give users a sense of the page structure while loading, reducing perceived wait time. - Place Suspense boundaries at the right granularity.
Too few boundaries = large parts of the UI blocked by one slow resource. Too many = excessive fallback flicker. Aim for one boundary per independent loading region. - Create resources outside components.
Data resources (using the manual pattern) must be created outside component render functions to avoid restarting fetches on every render. - Use useTransition for navigation/tab switches.
When the user switches tabs or routes, useuseTransitionto avoid showing the fallback immediately — stay on the current view while the next one loads. - Use route-based code splitting as a baseline.
Every SPA should at minimum useReact.lazy+Suspensefor route-level code splitting. This alone can dramatically improve initial page load times. - Do not put the fallback inside a Suspense that also suspends.
The fallback prop must render synchronously. Never put a lazy-loaded or data-fetching component as the fallback. - Prefer library-managed Suspense (React Query, SWR) over manual resources in production.
Manual resource wrappers are great for understanding internals, but production apps benefit from caching, deduplication, refetching, and devtools that libraries provide.
10. Common Mistakes to Avoid
- Mistake 1: Creating resources inside the component body
-
// ❌ WRONG — re-creates the resource and re-fetches on every render function UserProfile() { const resource = createResource(() => fetch('/api/user').then(r => r.json())); const user = resource.read(); ... } // ✅ CORRECT — create outside or in a stable reference const userResource = createResource(() => fetch('/api/user').then(r => r.json())); function UserProfile() { const user = userResource.read(); ... } - Mistake 2: Forgetting the Error Boundary
-
// ❌ No error handling — network failure = crashed UI <Suspense fallback={<p>Loading...</p>}> <DataComponent /> </Suspense> // ✅ Always wrap in ErrorBoundary <ErrorBoundary> <Suspense fallback={<p>Loading...</p>}> <DataComponent /> </Suspense> </ErrorBoundary> - Mistake 3: Using React.lazy on named exports directly
-
// ❌ WRONG — React.lazy requires a default export const MyComp = lazy(() => import('./MyComp').then(mod => mod.MyComp)); // ✅ CORRECT approach for named exports const MyComp = lazy(() => import('./MyComp').then(mod => ({ default: mod.MyComp })) ); - Mistake 4: Using Suspense for non-async components
- Suspense only works when something actually throws a Promise. If your component renders synchronously, wrapping it in Suspense does nothing (though it won’t break anything either).
- Mistake 5: Expecting Suspense to work with useEffect data fetching
-
// ❌ This does NOT work with Suspense — useEffect runs after render function MyComp() { const [data, setData] = useState(null); useEffect(() => { fetch('/api/data').then(r => r.json()).then(setData); }, []); if (!data) return <p>Loading...</p>; // This bypasses Suspense entirely return <p>{data.name}</p>; }Suspense requires the Promise to be thrown during rendering, not after it (as useEffect is after-render).
11. FAQ — Frequently Asked Questions
- Q1: Is Suspense only for React 18?
- No.
React.lazy+Suspensefor code splitting has been available since React 16.6. However, Concurrent Mode features (likeuseTransition,useDeferredValue, and streaming SSR) require React 18+. - Q2: Can I use Suspense with class components?
- The
<Suspense>boundary itself can wrap class components. But class components themselves cannot be lazy-loaded withReact.lazyunless they are the default export of the module. Error Boundaries, however, must still be class components. - Q3: Does Suspense replace Redux or other state managers?
- No. Suspense is about coordinating async loading states, not global state management. You can use Suspense alongside Redux, Zustand, Context API, or any other state manager.
- Q4: What happens if I don’t use a fallback prop?
- The
fallbackprop is required on a<Suspense>boundary. If you omit it, React will throw a warning in development mode. Passfallback={null}if you want no visible loading UI. - Q5: Can Suspense handle multiple async resources simultaneously?
- Yes. When multiple children inside one Suspense boundary are suspended, React shows the fallback until ALL of them resolve. This is called “waterfalling” if they resolve in sequence. To avoid performance issues, initiate all fetches before rendering (i.e., create all resources at the top level), so they fetch in parallel.
- Q6: Will Suspense cause layout shift (CLS)?
- It can, if the fallback and real content have different sizes. Use skeleton screens that match the real layout dimensions to minimize Cumulative Layout Shift (CLS). This is an important consideration for Core Web Vitals.
- Q7: Is it safe to use Suspense for data fetching in React 18 today?
- Yes, but the recommended approach is to use a Suspense-compatible library like TanStack Query (React Query) or SWR, which implement the Promise-throwing protocol correctly and handle caching, revalidation, and error cases robustly. The manual resource pattern shown in this tutorial is for educational purposes and not production-recommended.
- Q8: What is the difference between Suspense and React.memo?
- They solve entirely different problems.
React.memois a performance optimization that prevents a component from re-rendering when its props haven’t changed.Suspenseis for handling async loading states. They can be used together without conflict. - Q9: Can I use Suspense in React Native?
- Yes. React Native supports
React.lazy+Suspensefrom version 0.64+ (with Metro bundler). The same patterns apply — lazy-load heavy screens and wrap them in Suspense with a fallback. - Q10: How do I preload a lazy component before the user navigates to it?
-
const LazyPage = lazy(() => import('./HeavyPage')); // Preload on hover — before the user clicks function NavLink() { const preload = () => import('./HeavyPage'); // Fires the import early return ( <a href="/heavy" onMouseEnter={preload}> Go to Heavy Page </a> ); }The browser caches the module, so when React.lazy actually fires its own import, it resolves from cache instantly.
- Q11: What is “Fetch on Render” vs “Render as You Fetch”?
-
- Fetch on Render (old pattern): Component renders first, then useEffect fires a fetch, then component re-renders with data. Causes waterfalls and loading flicker.
- Render as You Fetch (Suspense pattern): Fetch is initiated before or during rendering. The component suspends if data isn’t ready yet. Much faster and smoother.
- Fetch then Render: Fetch all data upfront before rendering anything. Simpler but slower perceived performance.
Suspense enables the “Render as You Fetch” pattern.
12. Summary
React Suspense is one of the most impactful features introduced into the React ecosystem. Here is a concise recap of everything covered in this tutorial:
| Concept | What It Does | React Version |
|---|---|---|
| React.lazy + Suspense | Code splitting — loads JS bundles on demand | 16.6+ |
| Suspense for Data Fetching | Suspend rendering until data is ready | 18+ (stable via libraries) |
| Streaming SSR | Stream HTML in chunks; show content as it resolves | 18+ |
| useTransition | Keep old UI visible while preparing new one | 18+ |
| useDeferredValue | Defer heavy re-renders to keep UI responsive | 18+ |
| SuspenseList | Coordinate reveal order of multiple Suspense boundaries | Experimental |
| Error Boundary | Catch errors thrown from suspended components | 16+ |
Key Takeaways
- Suspense centralizes loading state management, removing boilerplate from individual components.
- It works via the “throw a Promise” protocol — React catches it at the nearest Suspense boundary.
- Always pair Suspense with an Error Boundary for robust error handling.
- Use skeleton screens instead of spinners for professional-grade UX.
- In production, use React Query, SWR, or Relay for data fetching with Suspense.
- Route-based code splitting with React.lazy is the easiest win for any React SPA.
- React 18’s concurrent features (useTransition, streaming SSR) unlock the full power of Suspense.
What to Learn Next
