ReactLazy Loading Components

Lazy Loading Components

Every component you import ends up in your JavaScript bundle. When your app grows, this means a visitor downloading your homepage also downloads the code for the settings panel, the admin dashboard, and every chart library — even if they never open those pages. Lazy loading breaks your bundle into smaller chunks that are fetched on-demand, reducing the amount of JavaScript the browser must download and parse before the page becomes interactive.

React.lazy — The Core API

React.lazy accepts a function that calls a dynamic import() and returns the Promise. React defers the import until the component is actually rendered for the first time:

JSX
import { lazy, Suspense } from 'react'

// The import() is only triggered when <HeavyChart> is rendered
const HeavyChart = lazy(() => import('./HeavyChart'))

function Dashboard() {
  return (
    <Suspense fallback={<p>Loading chart…</p>}>
      <HeavyChart />
    </Suspense>
  )
}
Note
React.lazy requires the imported module to have a default export. Named exports are not supported directly — if your component uses a named export, create a thin re-export file: export { MyComponent as default } from './MyComponent'
Why Suspense Is Required

While a lazy component's code is being downloaded, React has nothing to render. <Suspense> provides the fallback UI for this window. Without it React throws an error. The fallback can be anything — a spinner, a skeleton screen, or even null for invisible loads:

JSX
import { lazy, Suspense } from 'react'

const UserSettings = lazy(() => import('./UserSettings'))

function App() {
  return (
    // fallback renders while UserSettings.js is being fetched
    <Suspense fallback={<SettingsSkeleton />}>
      <UserSettings />
    </Suspense>
  )
}

function SettingsSkeleton() {
  return (
    <div style={{ opacity: 0.4 }}>
      <div style={{ height: 24, background: '#ddd', marginBottom: 12 }} />
      <div style={{ height: 24, background: '#ddd', marginBottom: 12 }} />
      <div style={{ height: 24, background: '#ddd', width: '60%' }} />
    </div>
  )
}
Route-Based Splitting (Most Impactful)

The single highest-impact place to apply lazy loading is at the route level. Each page becomes its own chunk, so users only download the code for the page they actually visit. With React Router v6:

JSX
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'

// Each route is a separate chunk — users only download what they visit
const Home      = lazy(() => import('./pages/Home'))
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings  = lazy(() => import('./pages/Settings'))
const AdminPanel = lazy(() => import('./pages/AdminPanel'))

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<PageSpinner />}>
        <Routes>
          <Route path="/"          element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings"  element={<Settings />} />
          <Route path="/admin"     element={<AdminPanel />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  )
}

function PageSpinner() {
  return (
    <div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
      <span>Loading page…</span>
    </div>
  )
}
Component-Based Splitting

Not all lazy loading needs to happen at the route level. Heavy below-the-fold components — rich text editors, data grids, image galleries — are excellent candidates:

JSX
import { lazy, Suspense, useState } from 'react'

// The rich editor is ~200 KB — only load it when the user clicks "Edit"
const RichTextEditor = lazy(() => import('./RichTextEditor'))

function BlogPost({ post }) {
  const [editing, setEditing] = useState(false)

  return (
    <article>
      {editing ? (
        <Suspense fallback={<p>Loading editor…</p>}>
          <RichTextEditor initialContent={post.body} />
        </Suspense>
      ) : (
        <>
          <div dangerouslySetInnerHTML={{ __html: post.body }} />
          <button onClick={() => setEditing(true)}>Edit</button>
        </>
      )}
    </article>
  )
}
Error Handling with ErrorBoundary

Network requests can fail. If the chunk download fails (bad connection, CDN error), the Suspense fallback stays visible indefinitely — and the user has no idea what happened. Wrap your <Suspense> in an ErrorBoundary to catch these failures and show a retry option:

JSX
import { Component, lazy, Suspense } from 'react'

class ChunkErrorBoundary extends Component {
  state = { hasError: false }

  static getDerivedStateFromError() {
    return { hasError: true }
  }

  render() {
    if (this.state.hasError) {
      return (
        <div>
          <p>Failed to load this section.</p>
          <button onClick={() => this.setState({ hasError: false })}>
            Retry
          </button>
        </div>
      )
    }
    return this.props.children
  }
}

const HeavyWidget = lazy(() => import('./HeavyWidget'))

function Page() {
  return (
    <ChunkErrorBoundary>
      <Suspense fallback={<p>Loading…</p>}>
        <HeavyWidget />
      </Suspense>
    </ChunkErrorBoundary>
  )
}
Preloading on Hover

There is a gap between when a user decides to click a button and when the click actually registers — typically 100-300 ms. You can use this time to start downloading the chunk on hover, making the subsequent load feel instant:

JSX
import { lazy, Suspense } from 'react'

const SettingsPanel = lazy(() => import('./SettingsPanel'))

// Calling import() a second time for the same module path returns the
// same cached Promise — so preloading is safe to call multiple times
function preloadSettings() {
  import('./SettingsPanel')
}

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

  return (
    <nav>
      {/* Start the download on hover, show on click */}
      <button
        onMouseEnter={preloadSettings}
        onFocus={preloadSettings}
        onClick={() => setShowSettings(true)}
      >
        Settings
      </button>

      {showSettings && (
        <Suspense fallback={<p>Loading settings…</p>}>
          <SettingsPanel />
        </Suspense>
      )}
    </nav>
  )
}
Next.js dynamic()

Next.js wraps React.lazy in its own dynamic() utility that adds SSR control and a built-in loading option:

JSX
import dynamic from 'next/dynamic'

// ssr: false — skip server rendering (useful for browser-only libraries)
const MapWidget = dynamic(() => import('./MapWidget'), {
  loading: () => <p>Loading map…</p>,
  ssr: false,
})

// With a named export
const LineChart = dynamic(
  () => import('./charts').then(mod => mod.LineChart),
  { loading: () => <p>Loading chart…</p> }
)

export default function Page() {
  return (
    <main>
      <MapWidget center={[51.5, -0.1]} />
      <LineChart data={chartData} />
    </main>
  )
}
  • React.lazy + <Suspense> is the standard pattern — use it in any React app.

  • Route-based splitting gives the biggest bundle savings with the least effort.

  • Component-based splitting targets heavy, conditionally-rendered widgets.

  • Always wrap lazy loads in an ErrorBoundary so network failures are recoverable.

  • Preload on onMouseEnter/onFocus to hide the latency gap before a click.

  • In Next.js, prefer dynamic() — it handles SSR control and aligns with the framework.

Tip
After adding lazy loading, open Chrome DevTools → Network tab → filter by "JS" and navigate between routes. You should see small numbered chunk files (e.g. 42.js, 87.js) loading on-demand instead of one massive bundle.