React Router 18 ⚛️ Lazy Loading — Code splitting route imports
The previous chapters covered the data loaders, the actions, the error boundaries, and the deferred data. This chapter covers the code splitting: the mechanism that loads the route component only when the route is first visited. The default route configuration imports all the components at the top of the file, which bundles them into the initial JavaScript payload. The user downloads the entire application before seeing the first page. The lazy loading splits the bundle per route, and the router loads the component chunk on demand. The LFCS exam does not test React Router, but the front-end engineering certification paths do. The critical distinction is between the lazy option in the route object and the React.lazy with the Suspense. The lazy option is the React Router’s native mechanism: it accepts a function that returns the module with the Component, the loader, the action, and the ErrorBoundary. The router handles the loading state, the error, and the prefetch. The React.lazy is the React’s native mechanism: it wraps a dynamic import and requires the <Suspense> boundary. The React Router’s lazy is the preferred for the route components.
Key point: The lazy option in the route object is the function that returns the dynamic import of the route module. The module exports the Component, the loader, the action, the ErrorBoundary, and the other route properties. The router loads the module when the route matches, and the loading state is the global navigation state. The alternative is the React.lazy with the <Suspense>, which is the React’s mechanism, not the router’s. The lazy is the recommended for the routes. The element with the <Suspense> is the correct for the component-level splitting.
Why lazy loading requires a specific mental model
The bundle problem. The default Vite or Webpack build bundles the entire application into the main.js. The user downloads the main.js before the first route. If the application has 50 routes with the heavy components (the admin dashboard, the chart editor, the settings page), the initial download is the 2 MB or more. The user waits for the download to see the login page.
The lazy problem. The lazy: () => import('./routes/dashboard') is the dynamic import. The bundler creates the separate chunk (dashboard-abc123.js). The router loads the chunk when the route matches. The main.js contains the router and the core, not the dashboard. The initial download is the small.
The module exports problem. The lazy module must export the route properties. The export const Component = Dashboard and the export const loader = dashboardLoader are the module-level exports. The router reads them. The export default is not used; the named exports are the contract.
The preload problem. The router prefetches the route on the link hover. The <Link to="/dashboard"> with the default preload="intent" triggers the lazy import on the mouse enter. The user clicks the link, and the chunk is already in the browser cache. The perceived load is instant.
The loading state problem. The lazy route loading is the part of the navigation.state === "loading". The useNavigation returns the "loading" during the chunk load. The global spinner (the top-bar indicator) is the loading state. The route-specific pendingComponent is not triggered because the component does not exist yet.
The trade-off. The lazy loading is the optimization for the initial download. The cost is the extra request per route. The network latency for the chunk load is the new wait. The preload on the link hover reduces the wait. The code splitting is the correct for the large application, not for the small.
a. The lazy option in the route object
The lazy option is the function that returns the dynamic import.
import { createBrowserRouter } from "react-router";
const router = createBrowserRouter([
{
path: "/dashboard",
lazy: () => import("./routes/dashboard"),
},
{
path: "/settings",
lazy: () => import("./routes/settings"),
},
]);
The lazy returns the promise of the module. The module is the dashboard.tsx file.
// src/routes/dashboard.tsx
export function Component() {
return <h1>Dashboard</h1>;
}
export async function loader() {
const res = await fetch("/api/dashboard");
return res.json();
}
The Component and the loader are the named exports. The router reads them. The lazy module can export the Component, the loader, the action, the ErrorBoundary, and the handle. The router merges them into the route.
The Component is the render function. The loader is the data fetch. The action is the mutation. The ErrorBoundary is the error UI. The handle is the metadata.
b. The file-based routing with the lazy loading
The TanStack Router and the React Router’s file-based routing use the lazy loading by default. The route file exports the Route with the component and the loader. The bundler creates the chunk.
// src/routes/dashboard.tsx (TanStack Router)
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard")({
component: Dashboard,
loader: async () => fetch("/api/dashboard").then((r) => r.json()),
});
function Dashboard() {
const data = Route.useLoaderData();
return <h1>{data.title}</h1>;
}
The file-based routing uses the route file as the chunk boundary. The plugin generates the route tree with the lazy references. The lazy is not manual; the file structure is the split.
The React Router’s file-based routing (createBrowserRouter with the route modules) is the alternative. The route module exports the Component, the loader, and the action. The router’s lazy loads the module.
c. The React.lazy and the <Suspense> boundary
The React.lazy is the React’s mechanism. The const Dashboard = React.lazy(() => import("./Dashboard")) wraps the dynamic import. The <Suspense> is the fallback.
import { lazy, Suspense } from "react";
import { createBrowserRouter } from "react-router";
const Dashboard = lazy(() => import("./Dashboard"));
const router = createBrowserRouter([
{
path: "/dashboard",
element: (
<Suspense fallback={<p>Loading...</p>}>
<Dashboard />
</Suspense>
),
},
]);
The React.lazy is the component-level. The router’s lazy is the route-level. The route-level is the preferred because it handles the loader and the error element. The component-level is the correct for the specific heavy component inside the route.
The Suspense boundary is the required for the React.lazy. The React.lazy throws the promise, and the <Suspense> catches it. The fallback is the loading state. The React.lazy does not work without the boundary.
The trade-off: the React.lazy is the React’s standard, but the router’s lazy is the integrated. The router’s lazy also handles the loader and the error. The component-level React.lazy is the correct for the non-route components (the chart editor, the heavy modal).
Complete Example Session
# ============================================
# PART 1: ROUTE OBJECT
# ============================================
const router = createBrowserRouter([
{ path: "/dashboard", lazy: () => import("./routes/dashboard") },
]);
# ============================================
# PART 2: ROUTE MODULE
# ============================================
// src/routes/dashboard.tsx
export function Component() { return <h1>Dashboard</h1>; }
export async function loader() { return fetch("/api/dashboard").then((r) => r.json()); }
# ============================================
# PART 3: ERROR BOUNDARY
# ============================================
export function ErrorBoundary() {
const error = useRouteError();
return <h1>Error: {error.message}</h1>;
}
# ============================================
# PART 4: LAZY ACTION
# ============================================
export async function action({ request }) {
const data = await request.formData();
await save(data);
return redirect("/dashboard");
}
# ============================================
# PART 5: PRELOAD ON HOVER
# ============================================
<Link to="/dashboard" preload="intent">Dashboard</Link>
# ============================================
# PART 6: REACT LAZY
# ============================================
const Chart = lazy(() => import("./Chart"));
<Suspense fallback={<p>Loading chart...</p>}><Chart /></Suspense>
# ============================================
# PART 7: FILE-BASED LAZY
# ============================================
// src/routes/dashboard.tsx (TanStack Router)
export const Route = createFileRoute("/dashboard")({ component: Dashboard });
# ============================================
# PART 8: LOADING STATE
# ============================================
const navigation = useNavigation();
{navigation.state === "loading" && <Spinner />}
# ============================================
# PART 9: VERIFY
# ============================================
# main.js → small
# dashboard-abc.js → loaded on /dashboard
# No chunk → error → error boundary
The nine parts trace the lazy loading: the route object, the module exports, the error boundary, the lazy action, the preload, the React.lazy, the file-based, the loading state, and the verification.
Quick Reference
Route lazy
| Element | Purpose |
|---|---|
lazy: () => import(...) | Dynamic import |
export function Component | Route component |
export async function loader | Route loader |
export async function action | Route action |
export function ErrorBoundary | Error UI |
Preload
| Option | Trigger |
|---|---|
preload="intent" | Hover or focus |
preload="render" | Render |
preload="none" | No preload |
React.lazy
| Element | Purpose |
|---|---|
lazy(() => import(...)) | Wrap the component |
<Suspense fallback={...}> | Loading boundary |
Navigation State
| State | Meaning |
|---|---|
"loading" | Chunk load in progress |
Best Practices
✅ Do This:
lazy: () => import("./routes/dashboard")
<!-- Use the route lazy for the route components --> # ✅
export function Component() { return <h1>Dashboard</h1>; }
<!-- Export the Component by name --> # ✅
<Link to="/dashboard" preload="intent">
<!-- Preload the chunk on the hover --> # ✅
❌ Don’t Do This:
# Import all components at the top
<!-- The initial bundle is the large --> # ❌
# Use the default export
<!-- The router expects the named exports --> # ❌
# Forget the Suspense for the React.lazy
<!-- The lazy throws the promise --> # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Chunk not loading | Wrong module export | Use the named exports |
| Blank screen | No Suspense for the React.lazy | Add the boundary |
| Slow navigation | No preload | Use preload="intent" |
| Error not caught | No ErrorBoundary export | Add the export |
| File-based not splitting | No route module | Check the file naming |
Real-World Examples
1. Route Lazy
lazy: () => import("./routes/dashboard")
2. Component Export
export function Component() { return <Dashboard />; }
3. Loader Export
export async function loader() { return fetch("/api").then((r) => r.json()); }
4. Error Boundary Export
export function ErrorBoundary({ error }) { return <h1>{error.message}</h1>; }
5. Preload
<Link to="/dashboard" preload="intent">Dashboard</Link>
6. React Lazy
const Chart = lazy(() => import("./Chart"));
7. Suspense
<Suspense fallback={<p>Loading...</p>}><Chart /></Suspense>
8. Navigation State
const isLoading = useNavigation().state === "loading";
9. File-Based
export const Route = createFileRoute("/dashboard")({ component: Dashboard });
10. Verify
main.js + dashboard-abc.js
Visual
Bundling Without Lazy
┌──────────────────────────────────────────────┐
│ main.js (2 MB) │
│ ├── Router │
│ ├── Dashboard │
│ ├── Settings │
│ ├── ChartEditor │
│ └── ... │
└──────────────────┬───────────────────────────┘
│
│ Download
▼
┌──────────────────────────────────────────────┐
│ User waits for the 2 MB │
│ → Sees the login after the download │
└──────────────────────────────────────────────┘
Bundling With Lazy
┌──────────────────────────────────────────────┐
│ main.js (200 KB) │
│ ├── Router │
│ ├── Login │
│ └── Lazy references │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ User sees the login immediately │
│ → /dashboard triggers the chunk load │
│ → dashboard-abc.js (300 KB) │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Route lazy | lazy: () => import(...) |
| Component export | export function Component |
| Loader export | export async function loader |
| Error export | export function ErrorBoundary |
| Preload | preload="intent" |
| React lazy | lazy(() => import(...)) |
| Suspense | <Suspense fallback={...}> |
Key takeaways:
- The
lazyoption is the route-level split. The function returns the dynamic import. The module exports theComponent, theloader, theaction, and theErrorBoundary. The router loads the chunk on the route match. - The named exports are the contract. The router reads the
Component, theloader, and the other exports. The default export is not used. The named exports enable the tree-shaking and the code splitting. - The preload reduces the latency. The
<Link preload="intent">triggers the chunk load on the hover. The user clicks the link, and the chunk is already in the cache. The perceived load is the instant. - The
React.lazyis the component-level. It requires the<Suspense>boundary. The router’slazyis the route-level and handles the loader and the error. The two are the different tools for the different scopes. - The file-based routing is the automatic. The route file is the chunk boundary. The plugin generates the lazy references. The manual
lazyis for the code-based routing.
Remember: The lazy is the route. The dynamic import is the chunk. The named exports are the contract. The preload is the hover. The Suspense is the React. The loading is the navigation. The exam tests the route lazy and the named exports.
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!