Code Splitting and Suspense

Course 3 · Ch 3
Code Splitting and Suspense
Loading parts of an app only when they're actually needed, instead of all at once on first load

Every component built so far has been bundled into the app's JavaScript from the very first page load, regardless of whether it's ever used in a given visit. As an app grows — more pages, more rarely-used features, larger third-party libraries (a charting library, a rich text editor) — that single bundle keeps growing too, even for a user who only ever visits one page. Code splitting breaks the bundle into smaller pieces, loaded on demand; Suspense is what shows something reasonable on screen while a piece is still being fetched.

React.lazy — Loading a Component on Demand

import { lazy, Suspense } from "react"; const SettingsPage = lazy(() => import("./SettingsPage")); function App() { return ( <Suspense fallback={<p>Loading...</p>}> <SettingsPage /> </Suspense> ); }

lazy(() => import("./SettingsPage")) doesn't load SettingsPage's code at all until the moment it's actually about to be rendered for the first time — the dynamic import() call kicks off a separate network request for just that component's code, fetched as its own small bundle ("chunk") rather than being part of the main one. While that chunk is loading, the nearest wrapping <Suspense fallback={...}> shows its fallback instead of the real component, swapping over automatically the instant loading finishes.

React.lazy needs a default export
lazy() expects the dynamically imported module to have a default export — import("./SettingsPage") resolves to an object, and lazy specifically looks for that object's .default property. A module exporting only a named export (export function SettingsPage() {...}, no export default) needs a small wrapper module re-exporting it as default, or needs to be changed to a default export directly.

Route-Based Splitting — The Most Common Use Case

import { lazy, Suspense } from "react"; import { BrowserRouter, Routes, Route } from "react-router-dom"; const HomePage = lazy(() => import("./pages/HomePage")); const SettingsPage = lazy(() => import("./pages/SettingsPage")); function App() { return ( <BrowserRouter> <Suspense fallback={<p>Loading page...</p>}> <Routes> <Route path="/" element={<HomePage />} /> <Route path="/settings" element={<SettingsPage />} /> </Routes> </Suspense> </BrowserRouter> ); }

Pairing lazy with the routes from Intermediate Chapter 5 is the single most common pattern: a user visiting / only ever downloads HomePage's code, SettingsPage's chunk never loads at all unless that user actually navigates to /settings. One Suspense boundary wrapping the whole Routes block is usually enough — every route shares the same fallback while its own page's chunk loads.

Loading a Rarely-Used Feature on Click

const AnalyticsChart = lazy(() => import("./AnalyticsChart")); function Dashboard() { const [showChart, setShowChart] = useState(false); return ( <div> <button onClick={() => setShowChart(true)}>Show analytics</button> {showChart && ( <Suspense fallback={<p>Loading chart...</p>}> <AnalyticsChart /> </Suspense> )} </div> ); }

Here, AnalyticsChart's code (likely a sizeable charting library) doesn't load on the initial page visit at all — only once showChart becomes true, the conditional rendering from Fundamentals Chapter 5 means React only tries to render <AnalyticsChart /> (triggering its chunk to load) at the exact moment a user actually wants it.

Nesting Suspense Boundaries

A single component can be wrapped in its own dedicated Suspense, separate from a larger one further up — useful when one part of a page should show its own specific loading state rather than blocking everything else on the page behind one shared fallback. There's a real tradeoff either way: fewer, broader boundaries mean simpler fallback UI but more of the page "waits together"; more, narrower boundaries let unrelated parts of a page appear independently, at the cost of more fallback states to design.

Don't lazy-load everything
Wrapping every small component in lazy "just in case" creates a waterfall of tiny network requests, often slower overall than one slightly larger bundle loaded once. Code splitting earns its keep at natural boundaries — whole routes, and genuinely large or rarely-used features (a chart library, a rich text editor, an admin-only panel) — not for ordinary, frequently-used, lightweight components.
Suspense for data — an emerging pattern
Newer versions of React and React Query are extending Suspense beyond just component code, letting a data-fetching hook also "suspend" rendering until its data arrives, using the same Suspense boundary and fallback shown here. The component-code-splitting pattern covered in this chapter is the stable, well-established baseline either way — worth knowing this direction exists, without needing to chase it immediately.

Coding Challenges

Challenge 1

Build a component lazy-loaded with React.lazy, wrapped in a Suspense boundary with a visible fallback ("Loading..."), and confirm in the browser's network tab that its code arrives as a separate request rather than being part of the main bundle.

📄 View solution
Challenge 2

Build a small multi-page app (using React Router from Intermediate Chapter 5) where every page component is lazy-loaded, with one shared Suspense boundary wrapping the Routes block.

📄 View solution
Challenge 3

Build a Dashboard with a button that, only when clicked, reveals a lazy-loaded component wrapped in its own Suspense boundary — confirming the lazy component's code is not requested at all until the button is actually clicked.

📄 View solution

Chapter 3 Quick Reference

  • lazy(() => import("./X")) — loads a component's code only when it's first rendered
  • <Suspense fallback={...}> — shows the fallback while a wrapped lazy component's chunk is loading
  • lazy requires a default export from the imported module
  • Most common use: route-based splitting, pairing lazy with React Router (Intermediate Ch 5)
  • Also useful for rarely-used features loaded only on demand (a click, a conditional render)
  • Don't lazy-load everything — apply it at route boundaries and genuinely large/rare features, not small everyday components
  • Next chapter: testing — Jest and React Testing Library