DEV Community

Cover image for Beyond Clean Code: Mastering Parallel Data Fetching with React Router's defer
Peter Ogbonna
Peter Ogbonna

Posted on

Beyond Clean Code: Mastering Parallel Data Fetching with React Router's defer

In my previous post, I discussed how to get rid of Messy State by moving from useEffect to React Router Loaders.

We successfully cleaned up our components, but we introduced a new challenge; The "Waiting Game."

Because standard Loaders fetch data before the route renders, if your API takes 3 seconds to respond, your user is stuck looking at the old page until that loader finishes. To the user, it feels like the app is frozen or the link is broken.

Today, we solve that with the Defer Pattern.

Understanding the Logic: What exactly is "defer"?

To understand defer, you have to think about how a normal loader works. Usually, a loader is like a waiter who won't leave the kitchen until every single dish in your order is cooked. If the steak takes 20 minutes, you don't even get your water until the steak is done.

defer changes the rules. It allows the waiter to bring the water and appetizers (Fast Data) immediately, while the steak (Slow Data) is still cooking.

The Magic of the Promise

When you use defer, you aren't returning raw data; you are returning a Promise.

  • The Loader: Tells React Router, "Here is some data I have now, and here is a 'ticket' (the promise) for data that is coming later.

  • The Component: Renders immediately. It doesn't wait for that "ticket" to resolve.

The Implementation: v6 vs v7

For v6, to make this work, we need a three-part harmony: the defer utility, the <Await> component, and React's built-in <Suspense>.
However, React Router recently evolved, in v7, we don't need defer as the router is smart enough to handle promises automatically.

Step 1: Edit the Loader

Instead of using await for every fetch, we only await the critical data.

In React Router v6

// for v6.4+ we use the "defer"
import { defer } from "react-router-dom";

export async function loader({ params }) {
  // CRITICAL DATA: We `await` this. The page won't load until we have them.
  const productReq = await fetch(`/api/product/${params.id}`);
  const product = await productReq.json();

  // NON-CRITICAL DATA: No `await` here! We pass the promise directly.
  const reviewsPromise = fetch(`/api/product/${params.id}/reviews`).then(res => res.json());

  return defer({
    product,
    reviews: reviewsPromise 
  });
}
Enter fullscreen mode Exit fullscreen mode

In React Router v7

// for v7 we use don't use the "defer"
export async function loader({ params }) {
  // CRITICAL DATA: We `await` this. The page won't load until we have them.
  const productReq = await fetch(`/api/product/${params.id}`);
  const product = await productReq.json();

  // NON-CRITICAL DATA: No `await` here! We pass the promise directly.
  const reviewsPromise = fetch(`/api/product/${params.id}/reviews`).then(res => res.json());

  return {
    product,
    reviews: reviewsPromise 
  };
}
Enter fullscreen mode Exit fullscreen mode

Step 2: The UI (Unwrapping the Promise)

Regardless of which version you use, the UI implementation is the same.

Since reviews is a Promise, we can't just .map() over it instantly. We use the <Await> component to "unwrap" it once it arrives.

import { useLoaderData, Await } from "react-router-dom";
import { Suspense } from "react";

export default function ProductPage() {
  const { product, reviews } = useLoaderData();

  return (
    <div>
      <h1>{product.title}</h1> {/* Renders instantly */}

      <Suspense fallback={<p>Loading reviews...</p>}>
        <Await resolve={reviews} errorElement={<p>Error loading reviews!</p>}>
          {(resolvedReviews) => (
            <ul>
              {resolvedReviews.map(r => <li key={r.id}>{r.comment}</li>)}
            </ul>
          )}
        </Await>
      </Suspense>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why this is a UX Game Changer

1. Faster Perceived Performance

The user sees the "shell" of the page and the primary content almost instantly. They can start reading the product description while the reviews are still "streaming" in over the wire.

2. No more "All-or-Nothing" UI

In the old useEffect days, if one API call failed, you often ended up with a giant error screen. With <Await>, you get an errorElement property. If the reviews fail, only that section shows an error. The rest of your page stays functional.

3. Better than "Global" Loading Spinners

Instead of a giant spinner that covers the whole screen, you can use Skeleton Screens in your fallback to reserve space, preventing that annoying "layout shift" when data pops in.

When to defer and when not to

Since the focus is user experience, wrong use can act against the goal

Scenario Use Standard await Use defer
Crucial Data (SEO/Meta tags) ✅ Yes ❌ No
Fast APIs (< 200ms) ✅ Yes ❌ No
Slow Analytics/Logs ❌ No ✅ Yes
Secondary Page Content ❌ No ✅ Yes

Conclusion

Introducing Loaders cleaned up our code in Part 1, but defer makes our apps feel like professional-grade software. By strategically choosing what data to wait for and what data to stream, you give your users a snappier, more reliable experience.

What’s your favorite way to improve user experience? Let me know in the comments!

Top comments (0)