Module 7: Real-World Usage
Code Splitting with lazy and Suspense
Why bundle size matters, how dynamic imports and React.lazy split your code, where to place Suspense boundaries, and how to preload.
By default, a bundler combines your whole app into one JavaScript file. Every user downloads, parses and runs all of it on their first visit, including the admin dashboard they'll never open and the charting library used on one page. For a small app that's fine. For a large one, the initial load can get slow, especially on a phone with a mediocre connection.
Code splitting means breaking the bundle into chunks that load on demand.
Dynamic import#
Normal import statements are static: the bundler sees them upfront and includes everything. The import() function is different. It loads a module at runtime and returns a promise.
const module = await import("./heavyChart.js");
module.drawChart(data);
When the bundler (Vite uses Rollup for production builds) sees import(), it puts that module and its own dependencies into a separate file, fetched only when the code runs.
React.lazy#
lazy lets you use a dynamic import to define a component:
import { lazy, Suspense } from "react";
const Reports = lazy(() => import("./pages/Reports.jsx"));
function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(true)}>Show reports</button>
{show && (
<Suspense fallback={<p>Loading reports...</p>}>
<Reports />
</Suspense>
)}
</>
);
}
What happens: until the first time Reports is rendered, nothing is downloaded. When it renders, React starts the import, and because the component isn't ready yet, it "suspends." The nearest Suspense boundary above it shows its fallback. When the chunk arrives, React renders the real component in its place.
render <Reports />
│
▼
chunk loaded? ── no ──► Suspense shows fallback ──► chunk downloads ──► render Reports
│
yes ──► render Reports immediately
Two requirements:
- The imported module must have a default export.
lazyexpects the promise to resolve to something with adefaultproperty. For a named export, remap it:lazy(() => import("./Charts.jsx").then((m) => ({ default: m.Chart }))). - Call
lazyat the top level of the module, never inside a component. Otherwise you create a new component type each render and remount it every time, the same problem as defining components inside components.
Where to split: routes first#
The most effective split is by route. Users on the home page don't need the code for the settings page.
import { lazy, Suspense } from "react";
import { Outlet } from "react-router";
const Dashboard = lazy(() => import("./pages/Dashboard.jsx"));
const Settings = lazy(() => import("./pages/Settings.jsx"));
function Layout() {
return (
<>
<Nav />
<Suspense fallback={<PageSpinner />}>
<Outlet />
</Suspense>
</>
);
}
The Nav stays visible while the page content loads. Putting the boundary around only the swapping part is what makes the experience feel smooth. React Router's lazy property on route objects does the same job at the router level, as shown in the routing lesson.
The second kind of candidate is any heavy component rarely seen: a rich text editor, a charting library, a PDF viewer, a code editor, a big modal. Load it when the user opens it.
Suspense boundaries#
A Suspense boundary catches anything below it that's still loading and shows its fallback. You can nest them to control granularity.
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
</Suspense>
If SalesChart suspends, only the inner fallback shows and Header stays on screen. A single boundary at the root means the whole page can turn into a spinner every time anything loads, which feels bad. Place boundaries where a loading placeholder makes sense visually.
Suspense also works with data fetching in libraries and frameworks that support it (TanStack Query has useSuspenseQuery), so learning the boundary placement pays off twice.
Pair it with an error boundary#
A lazy chunk can fail to load, for example when the user is offline or a new deployment removed the old file. That's a thrown error during render, so it needs an error boundary above the Suspense.
<ErrorBoundary fallback={<p>Couldn't load this part. Refresh to try again.</p>}>
<Suspense fallback={<Spinner />}>
<Reports />
</Suspense>
</ErrorBoundary>
The class component from module 2 or the react-error-boundary package both work.
Preloading#
The cost of lazy loading is a delay the first time. You can hide it by starting the download early, for example when the user hovers a link.
const loadReports = () => import("./pages/Reports.jsx");
const Reports = lazy(loadReports);
<Link to="/reports" onMouseEnter={loadReports} onFocus={loadReports}>
Reports
</Link>
Calling import() again is cheap: the browser and bundler cache the module, so by the time the user clicks, it's already there.
Seeing what you shipped#
Run the production build and read the output:
npm run build
dist/assets/index-4f8a1c.js 182.4 kB
dist/assets/Reports-9b2e10.js 64.7 kB ← separate chunk
dist/assets/Settings-31ac7d.js 12.9 kB
For a visual breakdown, use a bundle analyzer plugin such as rollup-plugin-visualizer. You'll quickly spot that one date library or icon pack that's 40% of your bundle. Fixing that, by importing only the specific icons you use or swapping a heavy library for a lighter one, often beats any amount of cleverness elsewhere.
Where people get burned#
- Splitting too finely, creating dozens of tiny chunks that cost more in requests than they save.
- Lazy-loading something on the critical first screen, which makes the first paint slower, not faster.
- Forgetting a default export.
- Calling
lazyinside a component. - A single
Suspenseat the root that blanks the whole app on every route change. - No error boundary, so a failed chunk load shows a blank page.
- Importing an entire library (
import _ from "lodash") when one function would do.
Practice#
- Take a three-page app and lazy-load two of the pages. Run
npm run buildbefore and after and compare the chunk list. - Open DevTools → Network, navigate to a lazy page and watch the new chunk download.
- Add hover preloading to the nav links and confirm the chunk is already cached when you click.
- Simulate a failed chunk (go offline before opening a lazy page) with and without an error boundary.