In the ever-evolving landscape of React, two features have emerged as game-changers for building responsive, user-friendly applications: Suspense and Concurrent Mode (now integrated into Concurrent Rendering). These tools address longstanding challenges in React development, such as managing loading states, prioritizing user interactions, and handling asynchronous operations more gracefully.
Whether you’re a seasoned React developer or just starting out, understanding these features will empower you to create apps that feel faster, smoother, and more intuitive. In this blog, we’ll dive deep into what Suspense and Concurrent Rendering are, how they work, their practical applications, and why they matter for modern React development.
Table of Contents
- Introduction: The Need for Better Asynchronous Handling
- What is React Suspense?
- Concurrent Mode vs. Concurrent Rendering: Clarifying the Terms
- How Concurrent Rendering Works
- Key Features Powered by Concurrent Rendering
- Practical Examples
- Benefits of Suspense and Concurrent Rendering
- Migration Tips: Adopting These Features
- Conclusion
- References
What is React Suspense?
React Suspense is a component that lets you “wait” for some asynchronous operation (like data fetching or code loading) and declaratively specify a fallback UI to show while waiting. Instead of manually managing isLoading flags, you wrap the asynchronous component with <Suspense>, and React handles the rest.
Suspense Basics: Declarative Loading States
At its core, Suspense works by “suspending” (pausing) the rendering of a component tree until an asynchronous operation completes. During the suspension, React displays a fallback UI (e.g., a spinner, skeleton, or loading message) defined via the fallback prop.
The simplest syntax looks like this:
<Suspense fallback={<LoadingSpinner />}>
<SomeComponentThatFetchesData />
</Suspense>
Here, <SomeComponentThatFetchesData> will “throw a promise” while it’s loading. React catches this promise, shows <LoadingSpinner>, and resumes rendering once the promise resolves.
Evolution of Suspense: From Code Splitting to Data Fetching
Suspense was first introduced in React 16.6 (2018) with limited support for code splitting (loading components dynamically). Over time, its scope expanded to include data fetching, a much-anticipated feature that matured with React 18 (2022).
- Code Splitting (React 16.6+): Use
React.lazyto dynamically import components, and wrap them in Suspense to show a fallback while the code loads. - Data Fetching (React 18+): Suspense now works with data fetching libraries (e.g., Relay, TanStack Query, or SWR) that support “suspending” during data loads. Frameworks like Next.js 13+ and Remix have fully embraced this pattern.
Concurrent Mode vs. Concurrent Rendering: Clarifying the Terms
You may have heard the term “Concurrent Mode” in older React discussions. To avoid confusion:
- Concurrent Mode was an experimental name for a set of features that allowed React to interrupt rendering. It was never a stable API.
- Concurrent Rendering is the modern, stable term for React’s ability to interrupt, pause, resume, or abandon rendering work. It’s the underlying engine that powers features like Suspense, transitions, and deferred values.
In short: Concurrent Rendering is the mechanism, and Suspense is one of the features built on top of it.
How Concurrent Rendering Works
In traditional React, rendering is synchronous and blocking: once React starts rendering a component tree, it can’t stop until it finishes (a “blocking render”). This is problematic for large apps or slow devices, as it freezes the UI during heavy computations.
Concurrent Rendering changes this by enabling non-blocking, interruptible rendering. Here’s how it works:
- Offscreen Rendering: React works on a “draft” version of the UI tree in memory (offscreen) without blocking the main thread.
- Prioritization: React assigns priorities to updates. Urgent updates (e.g., typing in a search box) are processed immediately, while non-urgent updates (e.g., filtering search results) can be paused or deferred.
- Commit Phase: Once the draft UI is ready, React “commits” the changes to the DOM in a single, atomic step, ensuring no partial UI states are visible to the user.
Key Features Powered by Concurrent Rendering
Concurrent Rendering unlocks several powerful features. Let’s explore the most impactful ones:
Suspense for Data Fetching
Suspense for Data Fetching lets you declaratively wait for data to load before rendering a component. Instead of writing:
// Traditional approach (boilerplate-heavy)
function UserProfile() {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(data => { setUser(data); setIsLoading(false); })
.catch(err => { setError(err); setIsLoading(false); });
}, []);
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return <div>{user.name}</div>;
}
You can write:
// With Suspense (declarative)
function UserProfile() {
const user = fetchUserData().read(); // Throws a promise if loading
return <div>{user.name}</div>;
}
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<UserProfile />
</Suspense>
);
}
For this to work, fetchUserData() must return an object with a read() method that throws a promise while loading. Libraries like Relay, TanStack Query (with suspense: true), and SWR (with suspense: true) handle this under the hood.
Transitions: Prioritizing Urgent Updates
The startTransition API lets you mark updates as “non-urgent,” so they don’t block urgent interactions like typing or scrolling. For example, when a user types in a search box:
- The input update (urgent) should happen immediately.
- The search results (non-urgent) can wait until the main thread is free.
import { useState, startTransition } from 'react';
function SearchBox() {
const [input, setInput] = useState('');
const [results, setResults] = useState([]);
function handleChange(e) {
// Urgent: Update the input immediately
setInput(e.target.value);
// Non-urgent: Defer search results until later
startTransition(() => {
setResults(searchData(e.target.value)); // Heavy computation
});
}
return (
<div>
<input value={input} onChange={handleChange} />
<ResultsList results={results} />
</div>
);
}
React will interrupt the startTransition update if a higher-priority event (like another keystroke) occurs, ensuring the UI feels responsive.
Deferred Values: Smoothing Non-Urgent Updates
The useDeferredValue hook is similar to startTransition but for derived values. It “defers” updating a value until the main thread is free, using the latest value once possible.
For example, if filtering a list based on user input:
import { useState, useDeferredValue } from 'react';
function SearchBox() {
const [input, setInput] = useState('');
// Defer updating `deferredInput` until the UI is idle
const deferredInput = useDeferredValue(input);
// Filter using the deferred value to avoid blocking
const results = filterList(list, deferredInput);
return (
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<ResultsList results={results} />
</div>
);
}
Here, deferredInput will “lag behind” input during rapid typing but catch up once the user stops, ensuring smooth input.
SuspenseList: Coordinating Multiple Loading States
When multiple Suspense components are rendering (e.g., a dashboard with multiple widgets), SuspenseList lets you control the order in which their fallbacks appear. Use the revealOrder prop to specify “forwards” (show in order), “backwards” (show reverse order), or “together” (show all at once).
import { Suspense, SuspenseList } from 'react';
function Dashboard() {
return (
<SuspenseList revealOrder="forwards">
<Suspense fallback={<WidgetSkeleton />}>
<UserWidget />
</Suspense>
<Suspense fallback={<WidgetSkeleton />}>
<StatsWidget />
</Suspense>
<Suspense fallback={<WidgetSkeleton />}>
<ActivityWidget />
</Suspense>
</SuspenseList>
);
}
Practical Examples
Let’s walk through concrete examples to see these features in action.
Example 1: Code Splitting with React.lazy and Suspense
Code splitting reduces initial bundle size by loading components only when needed. React.lazy dynamically imports a component, and Suspense shows a fallback while it loads.
import { Suspense, lazy } from 'react';
// Dynamically import the UserProfile component (loaded on demand)
const UserProfile = lazy(() => import('./UserProfile'));
function App() {
return (
<div>
<h1>My App</h1>
{/* Show Spinner while UserProfile loads */}
<Suspense fallback={<div>Loading profile...</div>}>
<UserProfile />
</Suspense>
</div>
);
}
Note: React.lazy only works with default exports. For named exports, wrap the import:
const { UserProfile } = lazy(() =>
import('./UserProfile').then(mod => ({ UserProfile: mod.UserProfile }))
);
Example 2: Data Fetching with Suspense
Using a Suspense-compatible data fetcher (e.g., TanStack Query with suspense: true), we can simplify data loading:
import { Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';
// Fetch function (returns a promise)
async function fetchUser() {
const res = await fetch('/api/user');
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
}
// Component that uses the data (suspends while loading)
function UserProfile() {
// useQuery with suspense: true will throw a promise if loading
const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser, suspense: true });
return <div>Hello, {user.name}!</div>;
}
// App component wraps UserProfile in Suspense
function App() {
return (
<Suspense fallback={<div>Loading user...</div>}>
<UserProfile />
</Suspense>
);
}
Example 3: Using Transitions for Smooth Search
Here’s how startTransition ensures typing feels responsive even during heavy filtering:
import { useState, startTransition } from 'react';
// Simulate a slow filter function
function filterProducts(query, products) {
// Add artificial delay to mimic heavy computation
for (let i = 0; i < 1e6; i++) {}
return products.filter(p => p.name.includes(query));
}
function ProductSearch({ products }) {
const [query, setQuery] = useState('');
const [filteredProducts, setFilteredProducts] = useState(products);
function handleQueryChange(e) {
const newQuery = e.target.value;
// Update the input immediately (urgent)
setQuery(newQuery);
// Defer filtering until the main thread is free (non-urgent)
startTransition(() => {
setFilteredProducts(filterProducts(newQuery, products));
});
}
return (
<div>
<input
type="text"
value={query}
onChange={handleQueryChange}
placeholder="Search products..."
/>
<ul>
{filteredProducts.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</div>
);
}
Benefits of Suspense and Concurrent Rendering
- Less Boilerplate: Replace manual
isLoading/errorstate management with declarative Suspense. - Smoother Interactions: Concurrent Rendering prioritizes urgent updates, eliminating jank during typing or scrolling.
- Better UX for Slow Networks: Fallbacks (spinners, skeletons) are shown consistently, avoiding partial or broken UI states.
- Flexible Loading Strategies:
SuspenseListlets you coordinate multiple loading states for complex UIs.
Migration Tips: Adopting These Features
- Upgrade to React 18+: Suspense for Data Fetching and Concurrent Rendering are stable in React 18.
- Start with Code Splitting: Use
React.lazyand Suspense for route-based or component-based code splitting (low risk, high reward). - Adopt Suspense-Ready Data Libraries: Use TanStack Query, SWR, or Relay with
suspense: trueto simplify data fetching. - Use Frameworks: Tools like Next.js 13+ (App Router) or Remix have built-in support for Suspense, reducing configuration overhead.
- Test with Strict Mode: React 18’s Strict Mode double-renders components to catch issues with concurrent rendering.
Conclusion
React Suspense and Concurrent Rendering represent a paradigm shift in how we handle asynchronous operations and rendering in React. By embracing declarative loading states and interruptible rendering, these features let you build apps that are faster, more responsive, and easier to maintain.
As the React ecosystem continues to evolve (e.g., with Server Components and improved Suspense for streaming), these tools will only become more critical. Start small with code splitting, experiment with data fetching, and gradually adopt transitions—your users (and future self) will thank you.