Lazy Loading Components with lazy() and Code Splitting

Learn how lazy() combined with dynamic import() splits a React app's JavaScript bundle into separate chunks loaded on demand, reducing the amount of code shipped on initial page load.

By default, a bundler like Vite or webpack packages every component your app imports — including ones a given user may never visit, like an admin settings page or a rarely-used chart view — into one large JavaScript bundle downloaded before anything renders. Code splitting breaks that single bundle into smaller chunks that load only when they're actually needed, and React's lazy() function is the piece that ties a dynamically-imported chunk to a component you can render normally in JSX.

Why bundle size matters and what code splitting fixes

Every kilobyte of JavaScript in the initial bundle has to be downloaded, parsed, and executed before a user's first meaningful interaction with the page, and on a slow connection or an underpowered device that cost is very real — a 2MB bundle that includes a full admin dashboard, a settings module, and a dozen rarely-visited routes is dead weight for the overwhelming majority of visits that never touch those screens.

Code splitting addresses this by generating multiple smaller JS files ('chunks') instead of one monolith, and loading each chunk lazily — only when the code inside it is actually about to run. A user visiting only the homepage downloads just the homepage's chunk (plus shared framework code); the admin dashboard's chunk never gets fetched unless that user navigates there.

This is a build-tool-level mechanism (Vite and webpack both implement it) that React's lazy() function hooks into: wherever you call import() as a function (rather than a static import ... from ... statement) inside your source, the bundler automatically treats that as a split point and emits a separate chunk for whatever module is being imported.

lazy() and dynamic import()

lazy(), imported from React, takes a function that returns a dynamic import() promise resolving to a module with a default export that is a component, and returns a new component you can render exactly like any other — const Settings = lazy(() => import("./Settings")); then later <Settings /> in JSX.

The dynamic import("./Settings") call is what triggers the actual network fetch of that chunk, and it only happens the first time the lazy-wrapped component is about to render — not when the module containing the lazy() call itself loads. This is the mechanism that defers the cost: the import() promise isn't even created until React tries to render <Settings /> for the first time.

Because fetching a chunk over the network is asynchronous, React needs somewhere to show UI while that fetch is in flight — that's the role of a Suspense boundary (covered in depth in its own lesson), which must wrap any lazy-loaded component and provide a fallback prop to render meanwhile.

Route-level splitting vs component-level splitting

The most common and highest-leverage use of lazy() is splitting by route: each top-level page component (Home, Dashboard, Settings, Admin) is wrapped in lazy() so a visitor only ever downloads the code for the route they're actually on. With a router like React Router, this typically means passing a lazy-wrapped component into each route's element prop, with a single Suspense boundary around the router's outlet handling the loading state for whichever route is currently being fetched.

Component-level splitting is more surgical: it targets a specific heavy component within an already-loaded page — a rich text editor, a charting library, a PDF viewer, a video player — that most visitors to that page won't necessarily trigger (for example, a chart that only renders after the user clicks 'View Analytics'). Wrapping just that component in lazy() keeps its (often large, dependency-heavy) code out of the page's main chunk entirely.

A good rule of thumb: split at route boundaries first, since that's where the biggest and most reliable savings usually live, then look for individual heavy components — anything pulling in a large third-party library — as a second pass once route splitting is in place.

What lazy() does not do

lazy() only works for default exports — the dynamically imported module must have a export default SomeComponent, since lazy() specifically expects a .default property on the resolved module. A named export requires a small wrapper, like lazy(() => import("./Chart").then(m => ({ default: m.Chart }))).

lazy() cannot be called conditionally or inside a component's render — like hooks, const X = lazy(() => import("./X")) should be defined once at module scope, outside any component function, so the same lazy component reference is reused across renders instead of creating a new lazy wrapper (and re-triggering a fetch) every time.

lazy() also doesn't reduce the total amount of code your app ships overall — it changes WHEN each piece downloads, not how much code exists. The win is entirely about deferring cost away from the critical initial load, not about writing less code.

lazy() with a dynamic import

The import("./Settings") call only fires the first time <Settings /> is actually rendered. Settings.tsx must have a default export: export default function Settings() { ... }.

import { lazy, Suspense } from "react";

const Settings = lazy(() => import("./Settings"));

function App() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Settings />
    </Suspense>
  );
}

Code Examples

Settings's chunk is never fetched at all until the user clicks 'Open Settings' — before that click, App's initial bundle doesn't include Settings's code, only the small lazy() wrapper reference. Expected result: Clicking the button triggers a network request for the Settings chunk; 'Loading settings…' shows briefly, then the real Settings UI renders once the chunk arrives.

const Settings = lazy(() => import("./Settings"));

Each route's page component gets its own chunk; a single Suspense boundary in Layout covers all of them, showing a generic page-loading state while whichever route the user navigated to is being fetched. Visiting only '/' means the Dashboard and Admin chunks are never downloaded for that session, shrinking the effective bundle size for that visitor substantially.

import { lazy, Suspense, useState } from "react";

const Settings = lazy(() => import("./Settings"));

function App() {
  const [showSettings, setShowSettings] = useState(false);

  return (
    <div>
      <nav>
        <button onClick={() => setShowSettings(true)}>Open Settings</button>
      </nav>
      {showSettings && (
        <Suspense fallback={<p>Loading settings…</p>}>
          <Settings />
        </Suspense>
      )}
    </div>
  );
}
import { lazy, Suspense } from "react";
import { createBrowserRouter, RouterProvider, Outlet } from "react-router-dom";

const Home = lazy(() => import("./pages/Home"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Admin = lazy(() => import("./pages/Admin"));

function Layout() {
  return (
    <Suspense fallback={<div className="page-loading">Loading page…</div>}>
      <Outlet />
    </Suspense>
  );
}

const router = createBrowserRouter([
  {
    element: <Layout />,
    children: [
      { path: "/", element: <Home /> },
      { path: "/dashboard", element: <Dashboard /> },
      { path: "/admin", element: <Admin /> },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

One bundle vs split bundles

Each lazy()-wrapped import() call becomes its own chunk, fetched only when that component is first rendered.

Without splitting:
[ Home + Dashboard + Admin + Settings + charts-lib ]  <- all downloaded up front

With lazy() + route splitting:
main.js   [ Home + shared framework code ]        <- downloaded first
dashboard.js [ Dashboard ]                          <- fetched only on /dashboard
admin.js  [ Admin ]                                 <- fetched only on /admin
settings.js [ Settings ]                            <- fetched only when opened

Important Rules

Define lazy() calls at module scope, never inside a component body or conditionally — the wrapped component reference should be created once and reused across renders.

Always wrap a lazy-loaded component in a Suspense boundary somewhere in its ancestor tree, or React will throw when it tries to render before the chunk has loaded.

lazy() requires the target module to have a default export; use a .then(m => ({ default: m.Name })) adapter for named exports.

Prioritize splitting at route boundaries first, then target individual large, infrequently-used components as a second pass.

Where code splitting pays off

Large single-page applications with many distinct sections — an e-commerce site with browsing, checkout, and a separate seller dashboard, or a SaaS product with a marketing site, app, and admin panel — rely on route-level code splitting as a baseline performance practice, since without it every visitor pays the download cost of code for sections of the app they may never use. Tools like Lighthouse and bundle analyzers (rollup-plugin-visualizer, webpack-bundle-analyzer) are commonly used to spot components worth lazy-loading by revealing which imports contribute disproportionately to bundle size.

function App() { const Settings = lazy(() => import("./Settings")); // re-created every render return <Settings />; } const Settings = lazy(() => import("./Settings")); // module scope, created once function App() { return <Settings />; }

Calling lazy() inside the component body creates a brand-new lazy component on every render, which React treats as an entirely different component type — this typically causes the fallback to flash repeatedly or the component to remount unnecessarily instead of ever settling into its loaded state.

const Chart = lazy(() => import("./Chart")); // Chart.tsx uses export const Chart =..., no default export const Chart = lazy(() => import("./Chart").then((module) => ({ default: module.Chart })));

lazy() specifically looks for a .default property on the resolved module; a named-export-only module resolves to an object without default, and lazy() fails at runtime unless you adapt the promise to shape it into { default: ... }.

[multiple-choice] When does the import() inside a lazy() call actually fetch the component's chunk? A. Immediately when the module containing the lazy() call is loaded B. The first time the lazy-wrapped component is rendered C. Only when the app is built for production D. Every time the parent component re-renders

B. The first time the lazy-wrapped component is rendered — The import() promise is created lazily — it isn't triggered until React actually attempts to render the lazy-wrapped component for the first time, which is what defers the network cost.

[multiple-choice] What must be true of a module passed to lazy(() => import("./X"))? A. It must export exactly one named export called X B. It must have a default export that is the component C. It must import React explicitly at the top D. It must be written in a separate repository

B. It must have a default export that is the component — lazy() expects the resolved module to have a .default property pointing at the component, i.e. a default export; named-export-only modules need a small .then() adapter.

[multiple-choice] Where should splitting effort generally be focused first for the biggest performance win? A. Splitting every single small component individually B. Route-level boundaries, since visitors only need the code for the route they're on C. Splitting only third-party library imports, never app code D. Splitting is equally valuable everywhere so order doesn't matter

B. Route-level boundaries, since visitors only need the code for the route they're on — Route-level splitting usually yields the largest, most reliable reduction in initial bundle size, since entire pages a visitor never navigates to are excluded from their download entirely.

Take an existing eagerly-imported import Reports from "./Reports" used only inside an admin page's 'Reports' tab, and convert it to a lazy-loaded component wrapped in Suspense so its code (and any heavy charting library it depends on) is excluded from the admin page's main chunk until that tab is opened.

const Reports = lazy(() => import("./Reports")); function AdminPage() { const [tab, setTab] = useState<"overview" | "reports">("overview"); return ( <div> <TabBar active={tab} onChange={setTab} /> {tab === "reports" && ( <Suspense fallback={<p>Loading reports…</p>}> <Reports /> </Suspense> )} </div> ); }

Replace the static import with const Reports = lazy(() => import("./Reports")), keep it at module scope, and wrap its usage inside the tab's conditional render in a Suspense boundary.