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:
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>
)
}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:
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:
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:
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:
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:
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:
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
ErrorBoundaryso network failures are recoverable.Preload on
onMouseEnter/onFocusto hide the latency gap before a click.In Next.js, prefer
dynamic()— it handles SSR control and aligns with the framework.