Introduction
React has come a long way from simple class components and lifecycle methods. We’ve seen hooks transform how we handle state, and now we’re stepping into an era of even more powerful features: Suspense and concurrent rendering. In a nutshell, Suspense makes it easier to handle async operations (like data fetching) by letting the component “pause” if it doesn’t have the data it needs yet. Meanwhile, concurrent rendering ensures your app can stay responsive, even when heavy lifting is happening behind the scenes.
In this post, I’ll walk you through how these features work together to create smoother, more maintainable React applications. Whether you’re looking to clean up your loading states or handle multiple updates without blocking the UI, we’ll cover the core concepts, code snippets, and best practices you can apply right away. Even if you’re new to React, stay tuned—by the end, you’ll see how these tools can elevate your development game without complicating your code.
A Quick Refresher on Suspense
Suspense is React’s built-in way of handling asynchronous operations without manually juggling multiple loading states. Think of it as a checkpoint in your component tree. Whenever a component "suspends"—meaning it’s waiting on data—React shows a fallback until everything is ready. Originally, Suspense was used for code-splitting (lazy-loading chunks of your app), but with React 18, it’s now powerful enough to handle data fetching too.
Here’s a quick snippet to illustrate how it works:
import React, { Suspense } from "react";
import UserProfile from "./UserProfile";
import LoadingSpinner from "./LoadingSpinner";
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<UserProfile />
</Suspense>
);
}
export default App;
Introduction to Concurrent Rendering
Concurrent rendering is one of the big additions in React 18 that helps your app stay responsive, even when heavy operations are in progress. Traditionally, React would render updates synchronously, which sometimes blocked the main thread and led to janky UI transitions. With concurrent rendering, React can work on multiple versions of the UI in the background and show you the most up-to-date one when it’s ready.
The main tool you’ll use is startTransition
(or the useTransition
hook in function components). This tells React that certain state updates can happen “in the background,” so urgent tasks—like responding to a user typing—don’t get blocked. Here’s a quick example:
import React, { useState, useTransition } from "react";
function ProductSearch() {
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState([]);
function handleSearch(e) {
const newQuery = e.target.value;
setQuery(newQuery);
startTransition(() => {
// Simulate a heavy computation or remote fetch
const newResults = expensiveSearchFunction(newQuery);
setResults(newResults);
});
}
return (
<div>
<input
type="text"
placeholder="Search products..."
value={query}
onChange={handleSearch}
/>
{isPending && <p>Searching...</p>}
<ul>
{results.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
}
Notice how we’re separating the direct user feedback from the heavier work. We update query
immediately, but defer the expensive search to the transition. If searching takes a while, React can keep the interface snappy rather than freezing on each keystroke. Under the hood, concurrent rendering lets React pause and resume work as needed, so the browser stays responsive and your user doesn’t feel stuck.
In this example, if UserProfile
is still fetching data, the component will “throw” a promise internally, telling React to switch to the <LoadingSpinner />
as a fallback. Once the data arrives, Suspense seamlessly renders <UserProfile />
without any extra state variables or effect dependencies. It’s a clean, centralized approach that keeps your components focused on displaying data rather than orchestrating loading states.
Data Fetching with React Suspense
When it comes to loading data, most of us have done the usual dance: call an API in useEffect
, keep a loading
state, and conditionally render the UI once the data arrives. Suspense offers a different approach—it lets the component “throw” a promise if data isn’t ready yet, so React can pause rendering and show a fallback until everything resolves.
Traditional vs. Suspense
Traditional Approach:
function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/user")
.then((res) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
});
}, []);
if (loading) return <p>Loading...</p>;
return <p>Hello, {user.name}!</p>;
}
We manually track loading
in state and render <p>Loading...</p>
until we have our user. It works, but can get messy when you have multiple pieces of data to fetch or more complex states.
Suspense Approach:
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; // Suspense fallback
if (status === "error") throw result; // Goes to error boundary
return result;
},
};
}
const userResource = createResource(() =>
fetch("/api/user").then((res) => res.json())
);
function UserProfile() {
// This will 'throw' a promise if data isn't ready
const user = userResource.read();
return <p>Hello, {user.name}!</p>;
}
Now, if userResource.read()
doesn’t have the data, it throws the promise, and React shows the fallback we set in the <Suspense>
boundary. That’s the magic trick of Suspense: when it encounters a thrown promise, it suspends rendering for that component tree.
Putting It All Together
Wrap UserProfile
in a <Suspense>
boundary:
import React, { Suspense } from "react";
import UserProfile from "./UserProfile";
function App() {
return (
<Suspense fallback={<p>Loading user info...</p>}>
<UserProfile />
</Suspense>
);
}
export default App;
This way, all your manual loading logic disappears from the component itself. The <Suspense>
boundary handles it for you, giving you a clean separation between fetching and rendering. And if you have several async tasks, you can add more Suspense boundaries to isolate different parts of your UI.
Libraries Like React Query and SWR
If you need more robust features—like caching, re-fetching, or pagination—check out libraries like React Query or SWR. Many of these tools also offer optional Suspense modes, so you can keep your data-fetching code clean while benefiting from powerful caching and refetching mechanisms.
Merging Suspense with Concurrent Features
Now that we’ve seen how to fetch data with Suspense and keep our UI responsive with concurrent rendering, it’s time to combine the two. The main idea is that Suspense manages your async data loading, while concurrency (startTransition
or useTransition
) keeps your interface snappy during state updates.
Let’s look at a practical example that fetches a list of products and filters them on the client side:
import React, { Suspense, useState, useTransition } from "react";
// 1. Create a resource for fetching products
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;
if (status === "error") throw result;
return result;
},
};
}
const productResource = createResource(() => {
return fetch("https://fakestoreapi.com/products").then((res) => res.json());
});
// 2. A component that uses Suspense to fetch data
function ProductList() {
const [filterText, setFilterText] = useState("");
const [isPending, startTransition] = useTransition();
// Suspends if products aren’t ready yet, triggering a fallback
const products = productResource.read();
// Filter the product list based on user input
const filteredProducts = products.filter((product) =>
product.title.toLowerCase().includes(filterText.toLowerCase())
);
// Use concurrency to keep UI responsive when changing the filter
function handleFilterChange(e) {
const newFilter = e.target.value;
startTransition(() => {
setFilterText(newFilter);
});
}
return (
<div>
<input
type="text"
value={filterText}
onChange={handleFilterChange}
placeholder="Search products..."
/>
{isPending && <p>Filtering products, please wait...</p>}
<ul>
{filteredProducts.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</div>
);
}
// 3. Wrap it all in a Suspense boundary for the initial fetch
export default function Store() {
return (
<Suspense fallback={<p>Loading products...</p>}>
<ProductList />
</Suspense>
);
}
How This Works
-
Data Loading
The
productResource
uses the same Suspense trick as before: if the data isn’t ready, it “throws” a promise, which triggers the<Suspense>
fallback (<p>Loading products...</p>
). -
Concurrent Rendering
For filtering, we’re using the
useTransition
hook. By callingstartTransition
, we mark the state update forfilterText
as “non-urgent,” allowing React to keep the interface responsive if filtering requires heavy computation or large data sets. -
User Experience
Even if filtering hundreds of products, the UI won’t lock up during typing. React can work in the background, only updating the list once it’s done calculating matches—while still letting the user type without delay.
This approach shows how Suspense and concurrency can be used together to enhance both the organization of your data-fetching logic and the performance of your UI. Instead of sprinkling loading
booleans everywhere and risking laggy filters, you get a streamlined system that cleanly handles async data and keeps your app smooth for the user.
Common Pitfalls and Best Practices
Even though Suspense and concurrent rendering can greatly streamline your workflow, there are a few nuances to watch out for. Here are some common mistakes developers make—and how to avoid them—along with a few tips to keep everything running smoothly.
6.1. Avoiding Fallback Loops
A frequent issue is nesting multiple Suspense boundaries in a way that causes your UI to flicker between loading states. For example, if your ProductList
is wrapped in one Suspense boundary, and each individual product item is wrapped in another, you might see repeated fallback UIs if each item suspends in turn. Generally, you want a Suspense boundary at a level where you can reasonably group async tasks together, rather than scattering them throughout your component tree.
6.2. Handling Errors Gracefully
Suspense doesn't handle errors by itself. If your data fetching fails and your resource “throws” an error, React will look for an Error Boundary. If you don’t have one, the error can bubble up and break the entire app. Here’s a simple error boundary example:
import React from "react";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <p>Something went wrong.</p>;
}
return this.props.children;
}
}
export default ErrorBoundary;
Wrap your Suspense component with this boundary to catch errors from data fetching or rendering logic. This way, your fallback isn’t just covering loading states—it’s also covering possible runtime failures.
6.3. Monitoring Performance
- When to Use Suspense: If you have a small fetch that resolves quickly, manually managing loading states might be simpler. But if you’re juggling multiple data sources, or you want a clean, centralized approach to loading states, Suspense makes a big difference.
-
startTransition vs. No Transition: Not every user interaction needs to be wrapped in
startTransition
. Reserve concurrency for heavy or complex state updates—otherwise, you’re adding extra complexity without seeing real benefits. - Profiling and Benchmarking: Tools like the React Profiler can help you measure performance gains or pinpoint bottlenecks in your Suspense or concurrency setups.
6.4. Integrating with Existing State Management
Libraries like Redux or MobX can coexist with Suspense, but you need to plan carefully. For instance, if you’re already storing fetched data in a global store, decide whether you’ll benefit from rewriting that logic to use Suspense boundaries. In many cases, a hybrid approach—where some data is managed locally with Suspense, and some remains in global state—can work well.
By keeping these tips in mind, you’ll dodge the most common pain points and harness Suspense and concurrent rendering in a way that keeps your app clean, responsive, and easier to maintain.
Final Thoughts and Further Exploration
React Suspense and concurrent rendering aren’t just buzzwords—they can genuinely transform how you structure your app and handle performance bottlenecks. Suspense frees you from sprinkling loading states everywhere, and concurrency lets your UI stay responsive, even if you’re crunching large data sets or running complex computations. By embracing these features, you’ll spend less time micromanaging state and more time building rich, user-friendly interfaces.
If you’re looking to expand your knowledge further:
-
Try Out Advanced Scenarios
Experiment with multiple Suspense boundaries in a single app, or combine Suspense with libraries like React Query to see how caching and background updates feel.
-
Explore Streaming Server Rendering
React also supports streaming server rendering, which can be paired with Suspense to progressively send chunks of HTML to the client before all data is ready. That’s a game-changer for page load times.
-
Measure Performance
Use the React Profiler and browser developer tools to measure how concurrency affects real-world interactions. You’ll get a clearer picture of whether your transitions are truly smoother.
-
Keep Learning
Check out the React docs for a deeper dive into experimental features like Server Components. And if you ever want to see more React tips and tricks, head over to my portfolio at melvinprince.io.
With these building blocks in place, you’ll be well on your way to creating React apps that look and feel more professional, all while keeping the codebase easier to maintain. It might feel like a lot at first, but once you get comfortable with Suspense and concurrency, you’ll wonder how you ever built complex applications without them.
Closing and Next Steps
Congratulations on getting this far! By now, you’ve seen how Suspense can streamline data loading and how concurrent rendering keeps your app responsive under heavy loads. The best way to truly master these concepts is to experiment—try adding Suspense boundaries around different parts of your own projects, and sprinkle in startTransition
or useTransition
to see how they handle complex updates.
Before you dive in, here are a few final pointers:
- Pick the Right Boundaries: Place Suspense boundaries where they logically group related data (like a user profile or a product list). Overusing them can cause nested loading states that confuse your users.
- Balance Complexity: Not every interaction needs concurrency. Save those transitions for when the data size or computational overhead is significant.
- Evaluate Libraries: If your app needs caching, pagination, or extra features, libraries like React Query or SWR offer advanced Suspense support.
Thanks for reading! If you want more React insights—or you’re curious about my other projects—feel free to drop by my portfolio at melvinprince.io. Happy coding, and enjoy your newly streamlined approach to data fetching and UI updates!
Top comments (1)
Great