DEV Community

Cover image for Using React Suspense with Data Fetching and Concurrent Rendering.
Melvin Prince
Melvin Prince

Posted on

Using React Suspense with Data Fetching and Concurrent Rendering.

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

How This Works

  1. 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>).

  2. Concurrent Rendering

    For filtering, we’re using the useTransition hook. By calling startTransition, we mark the state update for filterText as “non-urgent,” allowing React to keep the interface responsive if filtering requires heavy computation or large data sets.

  3. 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;

Enter fullscreen mode Exit fullscreen mode

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:

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

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

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

  4. 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)

Collapse
 
aman_kharwar_d6b34f8efc53 profile image
Aman kharwar

Great