| |

React 76 ⚛️ Code Splitting with React lazy

⏱️ Estimated read: 16 min | |

A React application’s JavaScript bundle grows with every feature, every third-party library, and every route. The bundle that was 200 KB at the MVP stage becomes 2 MB after a year of additions, and the user who only wants to view their profile is downloading the admin dashboard, the rich text editor, and the entire charting library . Code splitting is the technique that breaks that monolith into smaller chunks, loading only what the current page needs. React.lazy is the built-in mechanism that makes a component load on demand instead of at startup.

The mechanism is built on dynamic import(). A static import (import { Foo } from './Foo') tells the bundler to include Foo in the main bundle. A dynamic import (import('./Foo')) tells the bundler to create a separate chunk and load it only when that line of code executes . React.lazy wraps the dynamic import and returns a component that React can render like any other, but which triggers the chunk download only when it first appears in the tree. Suspense provides the fallback UI that renders while the chunk is loading . The three pieces work together: lazy creates the split point, the dynamic import() loads the chunk, and Suspense handles the gap.

Key point: React.lazy wraps a dynamic import() that must resolve to a module with a default export; the lazy component must be rendered inside a Suspense boundary with a fallback, or React throws an error; and the most effective split point is the route boundary, because users rarely visit every route in a single session.


Why code splitting matters more than it seems

The initial load problem. Every byte of JavaScript in the main bundle must be downloaded, parsed, and executed before the application becomes interactive. On a slow mobile connection, a 2 MB bundle can take ten seconds to become usable, even if the user only needs the code for the login page. Splitting the bundle means the login page’s code downloads and executes, and the dashboard’s code downloads only when the user navigates to the dashboard . The time to interactive drops because the initial payload contains only what the current route requires.

The route boundary is the best split point. Users do not visit every route in a single session. An unauthenticated visitor never loads the dashboard. A free-tier user never loads the admin panel. Splitting at route boundaries captures this: the dashboard chunk is downloaded only by users who navigate to the dashboard . This is why route-based splitting is the first optimization to apply and the one with the largest return for the least effort.

The third-party library problem. A charting library, a rich text editor, or a syntax highlighter can be hundreds of kilobytes. If the component that uses it is in the main bundle, the library is downloaded by every user, even those who never see the component. Splitting the component into a lazy chunk moves the library into that chunk, so it is downloaded only when the component is rendered .

The over-splitting problem. Every chunk is an additional HTTP request. Splitting a small utility component into its own chunk can make the total load slower, not faster, because the overhead of the extra request exceeds the benefit of the smaller initial bundle . The guidance is to split at route boundaries and for genuinely heavy components—charts, editors, maps, admin panels—not to split every component. With HTTP/2, a few dozen chunks are fine, but hundreds of tiny chunks defeat the purpose.


a. The basic pattern: lazy, Suspense, and a default export

The React.lazy function takes a function that returns a Promise resolving to a module. The module must have a default export that is a React component.

// Import lazy and Suspense from React
import { lazy, Suspense } from 'react'

// dynamic import() creates a separate chunk for LazyComponent
// the chunk is NOT downloaded at startup
// it downloads only when LazyComponent first renders
const LazyComponent = lazy(() => import('./LazyComponent'))

function App() {
  return (
    // Suspense shows the fallback while the chunk is downloading
    // without Suspense, React throws "no fallback UI was specified"
    <Suspense fallback={<div>Loading...</div>}>
      {/* LazyComponent is inside the boundary */}
      {/* React triggers the import() here, the first time it renders */}
      <LazyComponent />
    </Suspense>
  )
}

The import('./LazyComponent') is the split point. The bundler (Vite, webpack, or any modern bundler) sees the dynamic import and creates a separate file for that module and its dependencies. The file is not requested until LazyComponent is rendered inside the Suspense boundary .

The default export requirement is the most common source of errors. React.lazy expects the imported module to have a default property that is the component. A module with only named exports (export function MyComponent() {}) will produce the error “Element type is invalid” because lazy receives a module without a default . The fixes are to add a default export to the component file, create a re-export module (export { Dashboard as default } from './Dashboard'), or use the .then() mapping described below.

The Suspense boundary must be an ancestor of the lazy component. If the lazy component renders outside any Suspense, React throws an error because it has no fallback to show while the chunk loads . The boundary can be placed high in the tree (one boundary for the whole app) or close to the lazy component (a boundary per route). The closer the boundary, the more of the surrounding UI stays interactive while the chunk loads.


b. Named exports, route splitting, and error handling

Named exports. React.lazy does not accept a named export directly. The workaround is to map the module to an object with a default property:

// The component file uses a named export:
// export function Dashboard() { ... }

// import() resolves to the module object: { Dashboard: fn }
// .then() maps it to { default: fn } which lazy expects
const Dashboard = lazy(() =>
  // import() is the split point
  import('./Dashboard').then((module) => ({
    // module.Dashboard is the named export
    // we wrap it in a default property so lazy accepts it
    default: module.Dashboard,
  }))
)

This pattern is standard and appears in the React documentation and in production codebases . The alternative is a re-export module, which keeps the lazy call simple but adds a file. The .then() mapping is more common because it keeps the split point inline.

Route-based splitting. The most impactful application of React.lazy is at the router level. Each route component becomes a separate chunk, and the chunk downloads only when the user navigates to that route.

// Import the lazy and Suspense utilities
import { lazy, Suspense } from 'react'
// Import the router components
import { Routes, Route } from 'react-router-dom'

// Each route is a separate chunk
// the chunk is created by the bundler at build time
// the chunk is downloaded when the route is first rendered
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings = lazy(() => import('./pages/Settings'))
// AdminPanel is heavy; users who never visit /admin never download it
const AdminPanel = lazy(() => import('./pages/AdminPanel'))

function App() {
  return (
    // One Suspense boundary around all routes
    // it catches any lazy route that is still loading
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        {/* When the URL is /, Dashboard chunk loads */}
        <Route path="/" element={<Dashboard />} />
        {/* When the URL is /settings, Settings chunk loads */}
        <Route path="/settings" element={<Settings />} />
        {/* When the URL is /admin, AdminPanel chunk loads */}
        <Route path="/admin" element={<AdminPanel />} />
      </Routes>
    </Suspense>
  )
}

The AdminPanel chunk is downloaded only by users who navigate to /admin. The Settings chunk is downloaded only by users who navigate to /settings. A user who only visits the dashboard never downloads the admin panel’s code .

Error handling. Suspense handles the loading state but not the failure state. If the chunk download fails—network error, CDN outage, deployment that removed an old chunk—React throws an error, and without an error boundary, the entire application unmounts . The pattern is to wrap the Suspense boundary in an ErrorBoundary that catches the failure and shows a retry UI.

// Error boundaries must be class components
// there is no hook equivalent for getDerivedStateFromError
class ChunkErrorBoundary extends React.Component {
  // Initial state: no error
  state = { hasError: false }

  // Called when a descendant throws during render
  // returns the new state that triggers the fallback UI
  static getDerivedStateFromError() {
    return { hasError: true }
  }

  render() {
    // If an error was caught, show the retry UI
    if (this.state.hasError) {
      return (
        <div>
          Failed to load.
          {/* Reloading fetches the latest chunk manifest */}
          <button onClick={() => window.location.reload()}>
            Retry
          </button>
        </div>
      )
    }
    // Otherwise render the children normally
    return this.props.children
  }
}

The error boundary is a class component because the componentDidCatch and getDerivedStateFromError lifecycle methods have no hook equivalent . The retry button reloads the page, which fetches the latest manifest and the current chunk names.


Complete Example Session

The following session builds a small application with a route-based split, a component-level split for a heavy chart, and an error boundary that catches a failed chunk load.

// ============================================
// PART 1: THE ROUTE COMPONENTS (DEFAULT EXPORTS)
// ============================================
// pages/Dashboard.jsx
// A default export is required for lazy() to work directly
export default function Dashboard() {
  return <h1>Dashboard</h1>
}

// pages/Admin.jsx
// Also a default export
export default function Admin() {
  return <h1>Admin Panel</h1>
}

// ============================================
// PART 2: THE HEAVY COMPONENT (NAMED EXPORT)
// ============================================
// components/HeavyChart.jsx
// Named export means lazy() cannot use it directly
// it needs the .then() mapping shown below
export function HeavyChart({ data }) {
  // data is an array of points
  return <div>Chart with {data.length} points</div>
}

// ============================================
// PART 3: THE ERROR BOUNDARY
// ============================================
// Import Component for the class-based error boundary
import { Component } from 'react'

class ChunkErrorBoundary extends Component {
  // Track whether a chunk failed to load
  state = { hasError: false }

  // Catch the error thrown by the failed chunk
  static getDerivedStateFromError() {
    return { hasError: true }
  }

  render() {
    // If an error was caught, show a retry button
    if (this.state.hasError) {
      // window.location.reload() fetches the latest manifest
      // and the current chunk names
      return (
        <button onClick={() => window.location.reload()}>
          Retry
        </button>
      )
    }
    // No error: render the app normally
    return this.props.children
  }
}

// ============================================
// PART 4: THE APP WITH LAZY ROUTES AND CHART
// ============================================
// Import lazy and Suspense for the split points
import { lazy, Suspense, useState } from 'react'
// Import router components for route-based splitting
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'

// Route splits: each is a separate chunk
// the chunk is created by the bundler at build time
// the chunk is downloaded when the route is first rendered
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Admin = lazy(() => import('./pages/Admin'))

// Component split with named export mapping
// import() returns the module object
// .then() maps the named export to the default property
const HeavyChart = lazy(() =>
  import('./components/HeavyChart').then((m) => ({
    // m.HeavyChart is the named export
    default: m.HeavyChart,
  }))
)

function App() {
  // State that controls whether the chart is rendered
  // the chart chunk downloads only when this becomes true
  const [showChart, setShowChart] = useState(false)

  return (
    // The error boundary wraps the whole app
    // so a failed chunk shows a retry UI, not a blank screen
    <ChunkErrorBoundary>
      <BrowserRouter>
        <nav>
          {/* Link does not trigger a lazy load, only navigation does */}
          <Link to="/">Dashboard</Link>
          <Link to="/admin">Admin</Link>
        </nav>

        {/* Suspense shows the fallback while the route chunk loads */}
        <Suspense fallback={<div>Loading route...</div>}>
          <Routes>
            {/* When the URL is /, Dashboard chunk loads */}
            <Route path="/" element={<Dashboard />} />
            {/* When the URL is /admin, Admin chunk loads */}
            <Route path="/admin" element={<Admin />} />
          </Routes>
        </Suspense>

        {/* A separate Suspense for the chart keeps the nav interactive */}
        <button onClick={() => setShowChart(true)}>Show Chart</button>
        {showChart && (
          // The chart chunk loads only when showChart is true
          <Suspense fallback={<div>Loading chart...</div>}>
            <HeavyChart data={[1, 2, 3]} />
          </Suspense>
        )}
      </BrowserRouter>
    </ChunkErrorBoundary>
  )
}

The four parts cover the route components with default exports, the heavy component with a named export, the error boundary, and the app that combines route splitting with a component-level split. What the session demonstrates is that the route chunks are separate files that download on navigation, the chart chunk downloads only when the button is clicked, and the error boundary catches a failed download without unmounting the entire app.


Quick Reference

Core API

APIPurpose
lazy(() => import(...))Create a lazy component
<Suspense fallback={...}>Show fallback while loading
ErrorBoundaryCatch chunk load failures

Export Requirements

Export TypeLazy Syntax
Defaultlazy(() => import('./Comp'))
Namedlazy(() => import('./Comp').then(m => ({ default: m.Comp })))

Split Points

LevelWhen to Use
RouteEvery route (biggest win)
Heavy componentCharts, editors, maps, modals
Third-party libraryLarge dependencies used conditionally
Not splitSmall components, above-fold UI

Common Pitfalls

PitfallFix
“Element type is invalid”Add default export or use .then() mapping
“No fallback UI was specified”Wrap in Suspense
Blank screen on network errorWrap in ErrorBoundary
Bundler cannot analyzeUse static string in import()

Best Practices

✅ Do This:

// Split at route boundaries first
// the chunk is created by the bundler, downloaded on navigation
const Dashboard = lazy(() => import('./pages/Dashboard'))

// Wrap in Suspense with a meaningful fallback
// the fallback renders while the chunk downloads
<Suspense fallback={<PageSkeleton />}><Dashboard /></Suspense>

// Handle named exports with .then()
// maps the named export to the default property
const Chart = lazy(() => import('./Chart').then(m => ({ default: m.Chart })))

// Wrap Suspense in an ErrorBoundary for chunk failures
// catches the error thrown when a chunk fails to download
<ErrorBoundary><Suspense fallback={...}>...</Suspense></ErrorBoundary>

❌ Don’t Do This:

// Named export without mapping
// lazy() receives a module without a default property
const Chart = lazy(() => import('./Chart')) // Error if no default

// Lazy component outside Suspense
// React has no fallback to show while the chunk loads
<LazyComponent /> // Error: no fallback

// Dynamic import with a variable
// the bundler cannot analyze the path, so no chunk is created
lazy(() => import(`./${name}`)) // Bundler cannot analyze

// Split every small component
// the extra HTTP request outweighs the smaller bundle
const Button = lazy(() => import('./Button')) // Overhead exceeds benefit

Common Pitfalls

PitfallWhy It HappensFix
Invalid element typeNamed export, no default.then(m => ({ default: m.Comp }))
No fallback errorLazy outside SuspenseWrap in Suspense
Blank screen on failureNo error boundaryAdd ErrorBoundary
Chunk not createdDynamic pathUse static string in import()
Slower loadToo many small chunksSplit routes, not components

Real-World Examples

1. Route split

// The chunk is created at build time and downloaded on navigation
const Dashboard = lazy(() => import('./pages/Dashboard'))

2. Named export mapping

// .then() maps the named export to the default property lazy() expects
const Chart = lazy(() => import('./Chart').then(m => ({ default: m.Chart })))

3. Suspense fallback

// The fallback renders while the lazy component's chunk downloads
<Suspense fallback={<Spinner />}><LazyComp /></Suspense>

4. Error boundary

// Class component that catches errors thrown by failed chunk loads
class ChunkErrorBoundary extends Component { ... }

5. Prefetch on hover

// Trigger the import() on hover so the chunk is ready before the click
<Link onMouseEnter={() => import('./pages/Dashboard')}>Dashboard</Link>

6. Webpack chunk name

// The magic comment names the chunk for easier debugging
import(/* webpackChunkName: "dashboard" */ './pages/Dashboard')

7. Conditional modal

// The modal chunk downloads only when showModal is true
{showModal && <Suspense fallback={null}><Modal /></Suspense>}

8. Chart on demand

// The chart chunk downloads when the button is clicked
<button onClick={() => setShow(true)}>Show</button>
{show && <Suspense><Chart /></Suspense>}

9. Bundle analyzer

# Inspect the chunks produced by the build
npx vite build --mode production

10. Measure before/after

Lighthouse: initial bundle size, LCP, TTI

Visual

Code Splitting Flow

┌──────────────────────────────────────────────┐
│  Static import                               │
│  import { Dashboard } from './Dashboard'     │
│  → included in main bundle                   │
│                                              │
│  Dynamic import                              │
│  import('./Dashboard')                       │
│  → separate chunk                            │
│  → downloaded on demand                      │
└──────────────────────────────────────────────┘

Lazy + Suspense

┌──────────────────────────────────────────────┐
│  const Comp = lazy(() => import('./Comp'))   │
│         │                                    │
│         ▼                                    │
│  <Suspense fallback={<Spinner />}>           │
│    <Comp />                                  │
│  </Suspense>                                 │
│         │                                    │
│    ┌────┴────┐                               │
│    │         │                               │
│  chunk    chunk loaded                       │
│  loading       │                             │
│    │           ▼                             │
│    ▼         render Comp                     │
│  show Spinner                                │
└──────────────────────────────────────────────┘

Route Split

┌──────────────────────────────────────────────┐
│  Main bundle: Layout, Router, nav            │
│                                              │
│  Dashboard chunk: downloaded on /            │
│  Settings chunk: downloaded on /settings     │
│  Admin chunk: downloaded on /admin           │
│                                              │
│  User on / never downloads /admin code       │
└──────────────────────────────────────────────┘

Error Boundary

┌──────────────────────────────────────────────┐
│  Chunk fails to load (network error)         │
│         │                                    │
│         ▼                                    │
│  ErrorBoundary catches it                    │
│         │                                    │
│         ▼                                    │
│  Renders retry UI instead of blank screen    │
└──────────────────────────────────────────────┘

Summary

ItemValue
Split mechanismReact.lazy + dynamic import()
Fallback<Suspense fallback={...}>
Export requirementDefault export (or .then() mapping)
Best split pointRoute boundaries
Heavy componentsCharts, editors, modals, admin panels
Error handlingErrorBoundary around Suspense
Bundler analysisStatic string in import()
Over-splittingAvoid small chunks

Key takeaways:

  • React.lazy creates the split point. It wraps a dynamic import() that the bundler turns into a separate chunk. The chunk downloads only when the lazy component first renders .
  • The component must be inside Suspense. Without a Suspense boundary, React throws an error because it has no fallback to show while the chunk loads .
  • The module must have a default export. A named export requires .then(m => ({ default: m.Comp })) mapping. Without it, the error is “Element type is invalid” .
  • Route boundaries are the best split points. Users rarely visit every route, so splitting by route means the dashboard chunk is not downloaded by users who only visit the login page .
  • Heavy components are the second-best split points. A chart library, a rich text editor, or a modal that is only shown conditionally should be lazy-loaded so the library is not in the main bundle .
  • Suspense handles loading, not failure. A failed chunk download throws an error that unmounts the tree unless an ErrorBoundary catches it. The boundary should show a retry UI .
  • Do not split everything. Every chunk is a network request. Splitting small components adds overhead that exceeds the benefit. Split routes and genuinely heavy components .

Remember: Code splitting is not about making the code smaller. It is about making the initial download smaller. The user who lands on the login page should not download the admin dashboard. React.lazy and Suspense are the built-in tools that make this possible: lazy wraps the dynamic import, the bundler creates the chunk, and Suspense shows the fallback while the chunk loads. The route boundary is the first place to apply it, the heavy component is the second, and the error boundary is the safety net that prevents a failed chunk from blanking the page. The goal is not the smallest possible bundle—it is the smallest bundle that the current route needs.



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!