In a React Router v7 data router, what does a route object's lazy property load, and which route properties can it never supply?
answer
- matching must be known up front
- function form versus object form
- path, index, children stay static
- static definitions win
basics
~20 sroute.lazy loads a matched route's non-matching properties — Component, loader, action, ErrorBoundary and similar — on first navigation to it. It can never supply path, index, children, id or caseSensitive, because the router needs those to match before loading anything.
solid answer
~40 sIn a data router, `lazy` lets a route keep only what matching needs in the main bundle. When a navigation first matches that route, the router calls `lazy`, merges the returned properties onto the route, and waits for them before rendering — no Suspense boundary is involved. Since 7.5.0 there are two forms: a **function** returning an object of properties, and an **object** whose keys are individual async loaders (`loader: async () => …`), which lets the loader chunk start without waiting for the component chunk. Properties that drive matching — `path`, `index`, `children`, `id`, `caseSensitive` — and `lazy` itself can never be lazy, and the function form also cannot return `middleware`. If a property is defined statically and also lazily, the static one wins and the lazy value is ignored with a warning.
code
ts · 21 linesimport { createBrowserRouter } from "react-router";
import Root from "./Root";
export const router = createBrowserRouter([
{
path: "/",
Component: Root,
children: [
{
path: "reports/:reportId",
// Object form: each property loads independently.
lazy: {
loader: async () => (await import("./reports/loader")).loader,
Component: async () => (await import("./reports/ReportPage")).default,
ErrorBoundary: async () =>
(await import("./reports/ReportError")).default,
},
},
],
},
]);go deeper
Know that route.lazy loads a route's component and loader only when that route is first visited, keeping the first bundle smaller.
Explain why matching properties must stay static, how the router awaits lazy before rendering, and when the object form starts the loader earlier.
Audit a route table for shadowed lazy properties and function-form lazies that serialise loader behind component code, and pick the object form where data latency matters.
Decide how route code is split across a large app: which routes stay eager, how lazy boundaries align with team ownership, and when to move to framework-mode splitting.
## What `lazy` is for A **data router** (created with `createBrowserRouter`) holds the entire route tree as objects. Without code splitting, every route's component, loader and action ship in the first bundle. The route object's **`lazy`** property moves those into separately loaded chunks while keeping the route itself in the table. The rule that explains everything about `lazy`: **the router must be able to match URLs before it has loaded any lazy code.** So anything used for matching stays static, and everything else can be lazy. ## What happens at runtime 1. A navigation starts and the router matches the URL against the static route table. 2. For each matched route with a `lazy` property that has not been resolved yet, the router calls it. 3. The resolved properties are merged onto the route object; the result is cached, so later navigations do not call it again. 4. The router then runs the route's `loader` (now available) and renders its `Component` once everything the navigation needs has settled. Because the router awaits `lazy` inside the navigation, the route component is already present when React renders it. That is different from wrapping a component in `React.lazy`, which suspends during render and needs a Suspense boundary. ## Function form versus object form | | Function form | Object form (7.5.0+) | |---|---|---| | Shape | `lazy: async () => ({ Component, loader })` | `lazy: { loader: async () => …, Component: async () => … }` | | Granularity | one promise for all properties | one promise per property | | Loader timing | waits for whatever the function awaits | loader can run as soon as its own chunk arrives | | Lazy `middleware` | not allowed | allowed | The object form exists because a single function tends to import one module containing both loader and component, so the loader cannot start until the heavier component code has downloaded. Splitting per property lets data fetching begin earlier. ## What can never be lazy In the pinned source the router refuses these keys from `lazy`: - `path`, `index`, `caseSensitive` — needed to match the URL, - `children` — needed to know the tree shape, - `id` — needed to identify the route before loading, - `lazy` itself, - and, for the **function** form only, `middleware`. A refused key is ignored with a development warning, not thrown. ## A worked timeline Suppose a report page's component module is 300 KB and its loader module is 5 KB, and the user clicks a link to `/reports/42`. - **Function form importing one combined module:** the router calls `lazy`, the browser downloads the combined 305 KB chunk, and only then can the loader start its request. Data fetching waits behind component code. - **Object form with separate modules:** the router calls the `loader` and `Component` property loaders together. The 5 KB loader chunk lands first, the loader starts fetching immediately, and the component chunk downloads in parallel with the request. The navigation still completes only when both are ready, but the two waits now overlap instead of adding up. This is the whole argument for the object form, and it is the answer an interviewer is looking for when they ask *why* it was added. ## Static beats lazy If a route defines `loader` statically *and* its `lazy` returns a `loader`, the static one is kept and the lazy value is ignored with a warning. This allows a deliberate split — for example a small static loader so data starts immediately, with only the component lazy — but it also means a stale static property can silently shadow the code you think you are loading. ## Where the boundary of this topic lies `lazy` is React Router's own route-level splitting mechanism. General code-splitting strategy — how to size chunks, prefetch on hover, recover from a chunk that 404s after a deploy — belongs to React's code-splitting tooling and is a separate subject. In an interview the React Router part is: *`lazy` loads non-matching route properties on first match, has function and object forms, cannot touch matching properties, and static properties take precedence.*
- Why does the router refuse a lazily supplied path?Matching happens before any lazy code runs: the router has to know which routes match the URL in order to know which `lazy` functions to call. A path that only appeared after loading would create a chicken-and-egg problem, so `path`, `index`, `children`, `id` and `caseSensitive` must be static.
- What is the practical benefit of the object form over the function form?Each property gets its own promise, so a small loader module can download and start fetching while the heavier component chunk is still loading. With one function that imports a combined module, the loader cannot run until the whole module arrives.
saying these in an interview costs you the question
- route.lazy needs a Suspense boundary around the route element
- lazy can return the route's path so routes are discovered late
- A lazy loader overrides a statically defined loader
- The lazy function runs again on every navigation to the route
- The function form of lazy can return middleware