How do you use React.lazy and Suspense to load components only when needed?
`React.lazy(() => import('./X'))` returns a component that resolves on first render via dynamic import — the bundler emits a separate chunk. Wrap it in `<Suspense fallback={<Spinner/>}>` to show UI while the chunk downloads. Best for route-level code splitting and heavy components (charts, editors, modals) that aren't on the critical path.
Lazy loading lets you defer downloading a component's code until it's actually rendered.
Basic usage
import { lazy, Suspense } from 'react';
const Settings = lazy(() => import('./Settings'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<Settings />
</Suspense>
);
}The bundler (Vite/webpack/Rollup) sees import('./Settings') and emits a separate JS chunk. The chunk is fetched on first render of <Settings/>.
Route-level splitting
const Home = lazy(() => import('./pages/Home'));
const Profile = lazy(() => import('./pages/Profile'));
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</Suspense>Each route is its own chunk; users only download what they navigate to.
Component-level splitting (heavy widgets)
const Chart = lazy(() => import('./Chart')); // includes recharts/d3
function Dashboard() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Show chart</button>
{open && (
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
)}
</>
);
}A 200 KB chart library no longer ships in the initial bundle.
Preloading on intent
const Profile = lazy(() => import('./Profile'));
function Nav() {
return (
<a
href="/profile"
onMouseEnter={() => import('./Profile')} // warm the cache
>
Profile
</a>
);
}Hover triggers the fetch; by the time the user clicks, the chunk is in memory.
Named exports
React.lazy only takes default exports. Wrap to expose a named one:
const Chart = lazy(() =>
import('./charts').then(m => ({ default: m.Chart })),
);Error boundaries
A failed chunk fetch (network down, deploy rollover) throws. Wrap with an error boundary so users see a retry instead of a white screen.
<ErrorBoundary fallback={<RetryUI />}>
<Suspense fallback={<Spinner />}>
<LazyThing />
</Suspense>
</ErrorBoundary>Suspense fallback choices
- Skeleton matching the layout: best — no layout shift.
- Spinner centered: fine for modals/inline.
- Empty: fine if the chunk loads under 100 ms.
When NOT to lazy-load
- Components that are visible on first paint: you'd just add a flash.
- Tiny components: the chunk overhead exceeds the savings.
- Above-the-fold UI: defer below-the-fold instead.
Follow-up questions
- •How would you preload a lazy chunk on hover?
- •Why does React.lazy require a default export?
- •What happens if the chunk download fails?
Common mistakes
- •Wrapping the entire app in one Suspense — a single slow route blocks everything.
- •Lazy-loading components that are always visible — adds a spinner flash for no savings.
- •Forgetting an error boundary, then white-screening when a chunk 404s after deploy.
Performance considerations
- •Route-level splitting is the highest-ROI use. Component-level splitting matters for heavy deps (charts, rich text, editors). Always pair with preloading-on-intent so the network hop is hidden behind user latency (hover → click is ~150 ms).
Edge cases
- •Stale chunks after deploy: the user has the old index.html referencing a chunk hash that no longer exists. Fix with retry-on-fail or a SW that revalidates.
- •SSR + lazy: Next.js needs dynamic() instead of React.lazy in the Pages Router.
- •Suspense for data fetching requires a framework or a hand-rolled cache.
Real-world examples
- •Gmail, Twitter, every major SPA does route-level code splitting. Stripe Elements lazy-loads payment method UIs. Notion lazy-loads its block editor.