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
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:
loaderis 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’sErrorBoundary(orerrorElement) renders instead of the page.isRouteErrorResponsetells 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,isLoadingorignoreflag 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:
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:
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
loaderfetches data before rendering;useLoaderData()returns it.- Throw
data(message, { status })for expected failures and handle them withErrorBoundary. lazy: () => import(...)loads a route’s code only when it is visited.