| | |

React Router 11 ⚛️ Data Loaders — Fetching data with loaders

⏱️ Estimated read: 10 min | |

The previous chapters covered the navigation, the routes, and the path and query parameters. This chapter introduces the mechanism that fetches the data before the route renders: the loader. In the React Router, the loader is a function defined on the route object. It runs before the component renders, and its result is available to the component through the useLoaderData hook. The LFCS exam does not test React Router, but the front-end engineering certification paths do. The critical distinction is between the useEffect + useState pattern and the loader pattern. The useEffect pattern renders the component first with an empty state, then fetches the data, then re-renders with the data. The loader pattern fetches the data first, then renders the component with the data already available. The loader eliminates the empty-state flash and the loading spinner, but it requires the route to be defined in the Data mode (createBrowserRouter).

Key point: The loader function is defined on the route object. It receives a context object with params, request, and context. It returns the data (or a Promise that resolves to the data). The useLoaderData hook returns the typed result. The loader runs before the component, and the router waits for it to resolve. The errorElement handles the loader errors. The loader is not the replacement for the client-side data fetching with the polling and the mutations; it is the route-level initial data.


Why data loaders require a specific mental model

The render-order problem. In the useEffect pattern, the component renders first, the effect runs after the paint, the fetch starts, and the state updates when the data arrives. The user sees the empty state for the duration of the fetch. In the loader pattern, the router runs the loader before the component, waits for the data, and renders the component with the data. The user never sees the empty state. The loader is the correct choice for the route data that the page cannot render without.

The hook problem. The useLoaderData hook is only available inside a component rendered by the router. The <BrowserRouter> (the declarative mode) does not support the loaders. The createBrowserRouter (the data mode) is required. The loader runs in the router’s data layer, not in the component’s render cycle. The hook reads the resolved data from the router’s state .

The route-params problem. The loader receives the route parameters via the context. The loader: async ({ params }) => fetch(\/api/users/${params.id}`)is the correct way to use the route params in the loader. TheuseLoaderDatareturns the data; theuseParams` in the component returns the same params, but the loader runs first, so the params are already resolved.

The error problem. The loader can throw. The throw new Response("Not Found", { status: 404 }) is the standard way to signal a missing resource. The router catches the throw and renders the errorElement of the nearest route. The useRouteError hook in the error element returns the thrown error. The throw is the error mechanism; the return value is the success path.

The loaderDeps problem. By default, the loader runs when the route matches. The search params changes do not trigger a re-run because the loader does not depend on them. The shouldRevalidate function (or the loader‘s dependency on the request.url) is the mechanism for the re-run. The useSearchParams in the loader via the request.url is the pattern. The loader that re-runs on the search change is the loader that reads the search from the request.

The trade-off. The loader is the data-first pattern. The useQuery (TanStack Query, SWR) is the client-side caching and the polling. The loader is for the route data that the router manages; the useQuery is for the component data that the component manages. The two can coexist: the loader fetches the initial data, and the useQuery handles the mutations and the refetch.


a. The basic loader and the useLoaderData

The loader is the route property. The useLoaderData reads the result.

import { createBrowserRouter, useLoaderData } from "react-router";
import { RouterProvider } from "react-router/dom";

async function postsLoader() {
  const res = await fetch("/api/posts");
  if (!res.ok) throw new Response("Failed", { status: res.status });
  return res.json();
}

function PostsList() {
  const posts = useLoaderData();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

const router = createBrowserRouter([
  {
    path: "/posts",
    Component: PostsList,
    loader: postsLoader,
  },
]);

The postsLoader runs before the PostsList renders. The useLoaderData returns the parsed JSON. The component renders the list with the data already available.

The loader runs on the route match. The <Link to="/posts"> navigates to the route, the router runs the loader, and the component renders when the data is ready.


b. The route params and the request in the loader

The loader receives the route params and the request. The params.id is the dynamic segment. The request.url is the full URL with the search params.

async function postLoader({ params, request }) {
  const url = new URL(request.url);
  const sort = url.searchParams.get("sort") || "date";
  const res = await fetch(`/api/posts/${params.id}?sort=${sort}`);
  if (!res.ok) throw new Response("Not Found", { status: 404 });
  return res.json();
}

const router = createBrowserRouter([
  {
    path: "/posts/:id",
    Component: PostDetail,
    loader: postLoader,
  },
]);

function PostDetail() {
  const post = useLoaderData();
  return <h1>{post.title}</h1>;
}

The params.id is the path parameter. The request.url is the full URL. The url.searchParams.get("sort") reads the search param. The loader re-runs when the path param changes, and the search param is read on each run.

The request.signal is the abort signal. The fetch can be cancelled when the navigation is interrupted.

async function postLoader({ params, request }) {
  const res = await fetch(`/api/posts/${params.id}`, {
    signal: request.signal,
  });
  return res.json();
}

The request.signal is the standard Fetch API signal. The fetch accepts it, and the request is aborted when the router cancels the navigation (e.g., the user clicks another link).


c. The error handling and the errorElement

The loader that throws is caught by the errorElement of the nearest route.

import { useRouteError } from "react-router";

function PostError() {
  const error = useRouteError();
  return (
    <div>
      <h1>Error {error.status}</h1>
      <p>{error.statusText || error.message}</p>
    </div>
  );
}

const router = createBrowserRouter([
  {
    path: "/posts/:id",
    Component: PostDetail,
    loader: postLoader,
    errorElement: <PostError />,
  },
]);

The useRouteError returns the thrown error. The error.status is the HTTP status from the Response throw. The error.message is the error message. The errorElement is the render output for the error.

The global error element is the fallback.

const router = createBrowserRouter([
  {
    path: "/",
    Component: Root,
    errorElement: <RootError />,
    children: [/* ... */],
  },
]);

The root errorElement catches the errors from any child route. The route-specific errorElement catches the errors from that route.

The loader is the route-level data fetch. The error element is the route-level error UI. The two are the pair.


Complete Example Session

# ============================================
# PART 1: BASIC LOADER
# ============================================

async function loader() {
  const res = await fetch("/api/posts");
  return res.json();
}

const router = createBrowserRouter([
  { path: "/posts", Component: PostsList, loader },
]);
# ============================================
# PART 2: READ DATA
# ============================================

function PostsList() {
  const posts = useLoaderData();
  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}
# ============================================
# PART 3: ROUTE PARAMS
# ============================================

async function loader({ params }) {
  const res = await fetch(`/api/posts/${params.id}`);
  return res.json();
}
# ============================================
# PART 4: SEARCH PARAMS
# ============================================

async function loader({ request }) {
  const url = new URL(request.url);
  const sort = url.searchParams.get("sort");
  return fetch(`/api/posts?sort=${sort}`).then((r) => r.json());
}
# ============================================
# PART 5: ABORT SIGNAL
# ============================================

async function loader({ request }) {
  return fetch("/api/data", { signal: request.signal }).then((r) => r.json());
}
# ============================================
# PART 6: ERROR THROW
# ============================================

async function loader({ params }) {
  const res = await fetch(`/api/posts/${params.id}`);
  if (!res.ok) throw new Response("Not Found", { status: 404 });
  return res.json();
}
# ============================================
# PART 7: ERROR ELEMENT
# ============================================

function PostError() {
  const error = useRouteError();
  return <h1>Error {error.status}</h1>;
}

const router = createBrowserRouter([
  { path: "/posts/:id", Component: PostDetail, loader, errorElement: <PostError /> },
]);
# ============================================
# PART 8: LOADING STATE
# ============================================

const router = createBrowserRouter([
  { path: "/posts", Component: PostsList, loader },
], {
  // No spinner: the router waits for the loader.
});
# ============================================
# PART 9: VERIFY
# ============================================

# Navigate to /posts → loader runs → PostsList renders with data.
# /posts/1 → loader runs with params.id=1.
# /posts/abc → loader throws 404 → PostError renders.

The nine parts trace the loader: basic, data read, params, search, abort, error throw, error element, loading state, and verification.


Quick Reference

Route Properties

PropertyPurpose
loaderData fetching function
errorElementError UI
ComponentRoute component

Loader Context

PropertyPurpose
paramsRoute params
requestFetch Request
contextRouter context

Hooks

HookReturns
useLoaderData()Loader result
useRouteError()Thrown error
useNavigation()Navigation state

Error Throw

ThrowRenders
throw new Response("...", { status })errorElement

Best Practices

✅ Do This:

async function loader({ params }) {
  return fetch(`/api/posts/${params.id}`).then(r => r.json());
}
<!-- Use the params from the context -->              # ✅
if (!res.ok) throw new Response("Not Found", { status: 404 });
<!-- Throw the Response for the error -->             # ✅
const posts = useLoaderData();
<!-- Use the typed hook -->                          # ✅

❌ Don’t Do This:

# Use useEffect + useState for the route data
<!-- The loader is the correct tool -->              # ❌
# Assume the loader runs on the search change
<!-- Use request.url or shouldRevalidate -->          # ❌
# Forget the errorElement
<!-- Loader errors crash the route -->               # ❌

Common Pitfalls

PitfallWhy It HappensFix
Data undefinedUsed useState instead of loaderUse useLoaderData
Loader not runningWrong modeUse createBrowserRouter
Error crashesNo errorElementAdd the boundary
Search not refetchingLoader doesn’t read request.urlParse the URL
Stale dataNo revalidationUse useRevalidator

Real-World Examples

1. Basic Loader

async function loader() { return fetch("/api/posts").then(r => r.json()); }

2. Read Data

const posts = useLoaderData();

3. Route Params

async function loader({ params }) { return fetch(`/api/posts/${params.id}`).then(r => r.json()); }

4. Search Params

async function loader({ request }) {
  const url = new URL(request.url);
  return fetch(`/api/posts?sort=${url.searchParams.get("sort")}`).then(r => r.json());
}

5. Abort Signal

fetch("/api/data", { signal: request.signal });

6. Error Throw

if (!res.ok) throw new Response("Not Found", { status: 404 });

7. Error Element

const router = createBrowserRouter([{ path: "/", errorElement: <Error /> }]);

8. Read Error

const error = useRouteError();

9. Multiple Loaders

{ path: "/", loader: rootLoader, children: [{ path: "posts", loader: postsLoader }] }

10. Revalidate

const revalidator = useRevalidator();
revalidator.revalidate();

Visual

Loader Flow

┌──────────────────────────────────────────────┐
│  Navigate to /posts                          │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Router runs the loader                      │
│  → fetch data                                │
└──────────────────┬───────────────────────────┘
                   │
        ┌──────────┴──────────┐
        │                     │
        ▼                     ▼
┌───────────────┐     ┌───────────────┐
│  Success      │     │  Error        │
│  → render     │     │  → errorElement│
└───────────────┘     └───────────────┘

useEffect vs Loader

┌──────────────────────────────────────────────┐
│  useEffect pattern                           │
│  1. Render with empty state                  │
│  2. Fetch                                    │
│  3. Re-render with data                      │
│  → User sees the empty state                 │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Loader pattern                              │
│  1. Fetch (before render)                    │
│  2. Render with data                         │
│  → User never sees the empty state           │
└──────────────────────────────────────────────┘

Summary

ItemValue
Loader propertyloader in route
Read hookuseLoaderData()
Loader context{ params, request, context }
Error throwthrow new Response(...)
Error UIerrorElement
Error readuseRouteError()
RequirescreateBrowserRouter

Key takeaways:

  • The loader runs before the component. The router awaits the loader and renders the component with the data. The component does not have the loading state; the router handles the navigation state.
  • The useLoaderData returns the typed result. The data is the resolved value, not a promise. The component uses it directly.
  • The loader receives the params and the request. The params.id is the path param. The request.url is the search params. The request.signal is the abort signal.
  • The errorElement is the mandatory error UI. The loader can throw. The errorElement catches the throw and renders the error. The useRouteError reads the thrown error.
  • The loader is the route-level data. The useQuery is the component-level data with the polling and the mutations. The two can coexist: the loader fetches the initial data, and the useQuery handles the rest.

Remember: The loader is the data-first. The useLoaderData is the read. The errorElement is the failure. The request.url is the search. The params is the path. The useRouteError is the error. The exam tests the loader function and the useLoaderData hook.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!