Using Data Loaders in React Router 6.4+

After reading this you will be able to move data fetching out of useEffect and into route loader functions, wire them up with createBrowserRouter, read the result with useLoaderData, stream slow data with defer/Await, and mutate with action functions — all with typed, render-blocking data that eliminates the loading-spinner waterfall.

← Back to React Router Implementation

Prerequisites

Core Concept

Before 6.4, React Router only matched URLs to components; fetching was your problem, and the idiomatic answer — fetch inside useEffect after the component mounted — produced a render-then-fetch waterfall where every nested route waited for its parent to paint before its own request even started. The 6.4 data router, created with createBrowserRouter, inverts that: each route may declare a loader that the router invokes in parallel for the whole matched branch the moment a navigation begins, and it holds the transition until the data resolves. The component only renders once its data is ready, so it reads that data synchronously with useLoaderData and never renders a half-empty shell. Mutations get the symmetric treatment through action functions, thrown responses bubble to the nearest errorElement, and anything genuinely slow can be handed to defer so the shell paints immediately while a <Suspense>-wrapped <Await> streams the laggard in. The mental shift is that the route, not the component, owns its data lifecycle.

Implementation

The router below defines a parent list route and a nested detail route. The parent loads fast summary data eagerly and defers a slow reviews request; the detail route mutates through an action. Note the LoaderFunctionArgs typing and how thrown Responses route to errorElement.

// react-router-dom v6.4+
import {
  createBrowserRouter,
  RouterProvider,
  useLoaderData,
  useRouteError,
  isRouteErrorResponse,
  defer,
  Await,
  Form,
  redirect,
  type LoaderFunctionArgs,
  type ActionFunctionArgs,
} from "react-router-dom";
import { Suspense } from "react";

interface Product {
  id: string;
  name: string;
  price: number;
}
interface Review {
  id: string;
  body: string;
}

// A loader returns data (or a Response). Throw a Response to jump to errorElement.
async function productLoader({ params }: LoaderFunctionArgs) {
  const res = await fetch(`/api/products/${params.id}`);
  if (!res.ok) throw new Response("Not found", { status: res.status });
  const product = (await res.json()) as Product;

  // defer() returns immediately; the slow promise streams in via <Await>.
  const reviews = fetch(`/api/products/${params.id}/reviews`)
    .then((r) => r.json() as Promise<Review[]>);

  return defer({ product, reviews });
}

// An action handles non-GET submissions from <Form>. Returning a redirect
// navigates and re-runs the destination's loaders.
async function reviewAction({ request, params }: ActionFunctionArgs) {
  const form = await request.formData();
  await fetch(`/api/products/${params.id}/reviews`, {
    method: "POST",
    body: JSON.stringify({ body: form.get("body") }),
    headers: { "Content-Type": "application/json" },
  });
  return redirect(`/products/${params.id}`);
}

function ProductDetail() {
  // useLoaderData is typed loosely; assert the shape the loader returns.
  const { product, reviews } = useLoaderData() as {
    product: Product;
    reviews: Promise<Review[]>;
  };
  return (
    <article>
      <h2>{product.name}</h2>
      <p>{product.price} GBP</p>
      <Form method="post">
        <textarea name="body" />
        <button type="submit">Add review</button>
      </Form>
      <Suspense fallback={<p>Loading reviews…</p>}>
        <Await resolve={reviews} errorElement={<p>Reviews unavailable.</p>}>
          {(list: Review[]) => (
            <ul>{list.map((r) => <li key={r.id}>{r.body}</li>)}</ul>
          )}
        </Await>
      </Suspense>
    </article>
  );
}

function RouteError() {
  const error = useRouteError();
  if (isRouteErrorResponse(error)) {
    return <p role="alert">{error.status}{error.statusText}</p>;
  }
  return <p role="alert">Something went wrong.</p>;
}

const router = createBrowserRouter([
  {
    path: "/products/:id",
    loader: productLoader,
    action: reviewAction,
    element: <ProductDetail />,
    errorElement: <RouteError />,
  },
]);

export function App() {
  return <RouterProvider router={router} />;
}

The errorElement on a route catches anything a loader or action throws — including the Response objects thrown above — and renders in place of that route’s element, so a failed fetch never blanks the whole tree. Because loaders for the entire matched branch run concurrently, a nested detail route no longer waits for its parent list to render before requesting its own data.

Verification

Drive a real navigation and assert that data is present on first paint rather than after a spinner. Because loaders block the transition, a correct implementation shows content with no intermediate empty state.

// @playwright/test v1.44
import { test, expect } from "@playwright/test";

test("loader data is present on first paint of the detail route", async ({ page }) => {
  await page.goto("/products/42");
  // No spinner should have been necessary for the eager product data.
  await expect(page.getByRole("heading", { name: /./ })).toBeVisible();

  // Deferred reviews stream in under Suspense afterwards.
  await expect(page.getByRole("list")).toBeVisible();
});

test("a 404 from the loader renders the errorElement, not a blank page", async ({ page }) => {
  await page.goto("/products/does-not-exist");
  await expect(page.getByRole("alert")).toContainText("404");
});

For a quick manual check, open DevTools, throttle the network, and navigate client-side: the URL updates and the router holds the old page visible until the loader resolves, so you never see a flash of empty layout. Watch the Network panel to confirm parent and child loader requests fire together, not in sequence.

Gotchas

  • useLoaderData returns unknown-ish data with no automatic type inference from the loader; either assert the shape at the call site or adopt the typed loader generics so a refactor to the fetch shape surfaces at compile time.
  • A loader must return a value or a Response — returning undefined throws at runtime. To render nothing, return null explicitly.
  • defer only helps if the deferred promise is not awaited inside the loader; awaiting it there collapses the stream back into a blocking fetch and the <Suspense> fallback never shows.
  • Data routers require createBrowserRouter (or the memory/hash equivalents) plus RouterProvider; the older <BrowserRouter> JSX tree silently ignores loader/action, so a half-migrated app compiles but never fetches.

FAQ

Do I still need useEffect for data fetching with React Router 6.4? For route-level data, no — the loader fetches before the component renders and eliminates the render-then-fetch waterfall. Keep useEffect only for genuinely component-local, non-URL-driven side effects like subscriptions or event listeners.

Why does useLoaderData not infer my loader return type? React Router types it loosely because a route object and its component are decoupled, so the library cannot statically link them. Assert the shape at the call site, or use the typed loader helpers, so a change to the fetch response is caught at compile time.

When should I reach for defer and Await instead of returning data directly? Use defer when part of a route’s data is slow enough to hurt time-to-content but not critical for the first paint, such as reviews or recommendations. The shell renders immediately with the fast data and the slow promise streams in under Suspense.

How do actions differ from loaders? Loaders run on navigation and GET requests to read data before render; actions run on non-GET Form submissions to mutate data, and after an action completes the router automatically revalidates the affected loaders so the UI reflects the change without a manual refetch.

What happens if a loader throws? The thrown value bubbles to the nearest route with an errorElement, which renders in place of that route’s element. Throwing a Response lets you carry a status code that isRouteErrorResponse can read, keeping the rest of the layout intact instead of crashing the whole tree.