| | |

React Router 18 ⚛️ Lazy Loading — Code splitting route imports

⏱️ Estimated read: 10 min | |

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

ElementPurpose
lazy: () => import(...)Dynamic import
export function ComponentRoute component
export async function loaderRoute loader
export async function actionRoute action
export function ErrorBoundaryError UI

Preload

OptionTrigger
preload="intent"Hover or focus
preload="render"Render
preload="none"No preload

React.lazy

ElementPurpose
lazy(() => import(...))Wrap the component
<Suspense fallback={...}>Loading boundary

Navigation State

StateMeaning
"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

PitfallWhy It HappensFix
Chunk not loadingWrong module exportUse the named exports
Blank screenNo Suspense for the React.lazyAdd the boundary
Slow navigationNo preloadUse preload="intent"
Error not caughtNo ErrorBoundary exportAdd the export
File-based not splittingNo route moduleCheck 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

ItemValue
Route lazylazy: () => import(...)
Component exportexport function Component
Loader exportexport async function loader
Error exportexport function ErrorBoundary
Preloadpreload="intent"
React lazylazy(() => import(...))
Suspense<Suspense fallback={...}>

Key takeaways:

  • The lazy option is the route-level split. The function returns the dynamic import. The module exports the Component, the loader, the action, and the ErrorBoundary. The router loads the chunk on the route match.
  • The named exports are the contract. The router reads the Component, the loader, 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.lazy is the component-level. It requires the <Suspense> boundary. The router’s lazy is 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 lazy is 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.

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!