Skip to content

React Tutorial Routing and Application Structure

Route Loaders and Lazy Routes in React Router

Lesson 110 of 156 3 min read By Deepak Paswan Updated Oct 10, 2026

Full-project lesson · needs Node.js, npm and real project files, so it does not run in the in-page playground.

So far, pages fetched their data inside effects after rendering. In data mode, a route can declare a loader: a function that fetches the data before the page renders, so the component receives data that is already there. A route can also be lazy: its code is downloaded only when the user visits it.

(This lesson uses data mode, so it needs createBrowserRouter and RouterProvider; see Route Configuration.)

A route with a loader

src/main.jsx
JSX
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import {
  createBrowserRouter,
  RouterProvider,
  Link,
  Outlet,
  useLoaderData,
  useRouteError,
  isRouteErrorResponse,
  data,
} from 'react-router'
import './index.css'

// Pretend API: replace with fetch('/api/products') in a real app
const PRODUCTS = [
  { id: 1, name: 'Keyboard' },
  { id: 2, name: 'Mouse' },
]
const getProducts = async () => PRODUCTS
const getProduct = async (id) => PRODUCTS.find((p) => p.id === Number(id))

function Layout() {
  return (
    <div>
      <h1>Learning shop</h1>
      <nav><Link to="/">Home</Link> | <Link to="/products">Products</Link></nav>
      <Outlet />
    </div>
  )
}

function Home() {
  return <p>Welcome!</p>
}

function ProductList() {
  const products = useLoaderData() // the loader's return value, already loaded
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}><Link to={`/products/${p.id}`}>{p.name}</Link></li>
      ))}
    </ul>
  )
}

function ProductDetail() {
  const product = useLoaderData()
  return <h2>Product {product.id}: {product.name}</h2>
}

function RouteError() {
  const error = useRouteError()
  if (isRouteErrorResponse(error) && error.status === 404) {
    return <p role="alert">Page not found</p>
  }
  return <p role="alert">Something went wrong</p>
}

const router = createBrowserRouter([
  {
    path: '/',
    Component: Layout,
    ErrorBoundary: RouteError,
    children: [
      { index: true, Component: Home },
      { path: 'products', loader: getProducts, Component: ProductList },
      {
        path: 'products/:id',
        loader: async ({ params }) => {
          const product = await getProduct(params.id)
          if (!product) throw data('Not found', { status: 404 })
          return product
        },
        Component: ProductDetail,
      },
    ],
  },
])

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>,
)

(Tested in a browser, with the real router: the list and detail pages get their data from loaders, an unknown product and an unknown URL both reach the error screen.)

How it works:

  • loader is an async function. It receives { params, request } and returns data (or throws).
  • useLoaderData() returns that data in the route’s component, with no effect, no loading flag and no flash of an empty page.
  • throw data('Not found', { status: 404 }) creates a 404 response. The route’s ErrorBoundary (or errorElement) renders instead of the page. isRouteErrorResponse tells a response error apart from a crash.
  • Navigation waits for the loader before showing the new page. For slow data, useNavigation() tells you when something is pending, so you can show a progress indicator.
  • Loaders for a parent and its children run in parallel, which avoids “waterfalls” of requests.

Why loaders are nice

  • Less state code. No status, isLoading or ignore flag in components.
  • Faster pages. Data starts loading as soon as the user clicks, not after the component renders.
  • Built-in error handling through route error boundaries.
  • Revalidation after form actions: the router refreshes affected loaders for you.

They do not remove the need for good loading and error design (Loading States), but they move that work to the route level.

Actions

The sibling of a loader is an action: a function that handles a form submission (<Form method="post">) and runs on the client in data mode. After an action completes, loaders re-run so the page shows fresh data. See the React Router documentation for the details of forms and actions.

Lazy routes: load code on demand

A big app should not ship every page’s code on the first visit. Give a route a lazy function that imports its module when needed:

src/main.jsx
JSX
const router = createBrowserRouter([
  {
    path: '/',
    Component: Layout,
    children: [
      { index: true, Component: Home },
      { path: 'reports', lazy: () => import('./pages/Reports.jsx') },
    ],
  },
])

The lazy module exports the route’s pieces by name, such as Component, loader and ErrorBoundary:

src/pages/Reports.jsx
JSX
export function loader() {
  return fetch('/api/reports').then((r) => r.json())
}

export function Component() {
  const reports = useLoaderData()
  return <h1>{reports.length} reports</h1>
}

Vite turns each dynamic import() into a separate file. The first visit does not download Reports.jsx until the user goes to /reports. This is code splitting, covered more in Lazy Loading.

When to choose data mode

Choose it when your pages need data before they render, when you have forms that change server data, or when you want route-level error handling and code splitting. For a small app with static pages, declarative mode is simpler.

Key points

  • loader fetches data before rendering; useLoaderData() returns it.
  • Throw data(message, { status }) for expected failures and handle them with ErrorBoundary.
  • lazy: () => import(...) loads a route’s code only when it is visited.