In one minute
Every import at the top of a file puts that code in the page’s initial JavaScript, which the browser must download, parse and run before the page works, even for features most users never open. Code splitting loads a feature’s code on demand. The dynamic import('./x') returns a promise for a module, and bundlers turn each one into a separate file (a chunk). lazy(() => import('./x')) makes that into a component that suspends until its code arrives, with a <Suspense> boundary showing a fallback meanwhile. Two refinements: start the download early, when the user hovers or focuses the button (prefetch), and use a transition so React keeps the current screen instead of flashing the fallback.
You’ll be able to: lazy-load a component, prefetch it on intent, and avoid the loading flash with useTransition.
1import SizeChart from './size-chart' // part of the page's JS23function ProductPage() {4 const [show, setShow] = useState(false)5 return (<>6 <button onClick={() => setShow(true)}>Size chart</button>7 {show && <SizeChart />}8 </>)9}- trigger
import SizeChart from './size-chart' // part of the page's JSThe chart’s code is downloaded and run before the page even mounts, though most shoppers never open it. - commit phaseThen the page appears.
- commit phase
{show && <SizeChart />}Clicking is instant: the code is already there.
1const SizeChart = lazy(() => import('./size-chart'))23function ProductPage() {4 const [show, setShow] = useState(false)5 return (<>6 <button onClick={() => setShow(true)}>Size chart</button>7 <Suspense fallback={<p>Loading size chart…</p>}>8 {show && <SizeChart />}9 </Suspense>10 </>)11}- commit phaseThe page mounts without the chart’s code.
- event
<button onClick={() => setShow(true)}>Size chart</button>Click. The first render ofSizeChartcalls the loader… - commit phase
<Suspense fallback={<p>Loading size chart…</p>}>…and suspends, so the nearestSuspenseshows its fallback while the code downloads. - commit phase500 ms later the module arrives; React retries and shows the chart.
1const loadSizeChart = () => import('./size-chart')2const SizeChart = lazy(loadSizeChart)34<button onMouseEnter={loadSizeChart} onFocus={loadSizeChart}5 onClick={() => setShow(true)}>Size chart</button>6<Suspense fallback={<p>Loading size chart…</p>}>7 {show && <SizeChart />}8</Suspense>- event
<button onMouseEnter={loadSizeChart} onFocus={loadSizeChart}Hovering the button starts the download early. - triggerThe code is ready before the click.
- event
onClick={() => setShow(true)}>Size chart</button>The click callsimport()again (focus, thenlazy’s first render). No new download: modules are cached by URL. - commit phase
<Suspense fallback={<p>Loading size chart…</p>}>But the fallback still appeared: animport()promise never resolves synchronously, solazy’s first render suspends anyway. - commit phaseThe chart replaces it moments later.
1const [isPending, startTransition] = useTransition()23<button onMouseEnter={loadSizeChart}4 onClick={() => startTransition(() => setShow(true))}>5 Size chart {isPending && '⏳'}6</button>7<Suspense fallback={<p>Loading size chart…</p>}>8 {show && <SizeChart />}9</Suspense>- event
onClick={() => startTransition(() => setShow(true))}>Click (the hover started the download a moment before). - commit phase
Size chart {isPending && '⏳'}A transition that suspends keeps the current screen instead of showing a fallback.isPendingshows a small ⏳ in the button. - commit phaseThe code arrives, and the chart replaces the old screen in one step. Recorded: no “Loading size chart…” at all.
The example: the size chart
Product pages have a “Size chart” button that few shoppers click. Its module logs size-chart.tsx evaluated when it runs. For the recordings, the dev server delays that file by 500 ms, like a slow network.
function PageStatic() {
const [show, setShow] = useState(false)
return (
<>
<button id="show" onClick={() => setShow(true)}>Size chart</button>
{show && <StaticSizeChart />}
</>
)
}
With a normal import SizeChart from './size-chart' at the top, the recorded order, from page load to clicking the button:
size-chart.tsx evaluated
page mounted
chart shown
The chart’s code ran before the page even mounted.
1. lazy + Suspense
const loadSizeChart = () => (log('import() called'), import('./size-chart'))
const SizeChart = lazy(loadSizeChart)
function PageLazy() {
const [show, setShow] = useState(false)
return (
<>
<button id="show" onClick={() => setShow(true)}>Size chart</button>
<Suspense fallback={fallback}>{show && <SizeChart />}</Suspense>
</>
)
}
page mounted
import() called
fallback shown
size-chart.tsx evaluated
chart shown
Nothing loads until SizeChart first renders. Then lazy calls the loader, the component suspends, the nearest Suspense shows “Loading size chart…”, and React retries once the module arrives. The module must default-export the component (for a named export: import('./x').then((m) => ({ default: m.SizeChart }))), and lazy must be called outside any component, or it creates a new component (and loses state) every render.
2. Prefetch on hover and focus
function PagePrefetch() {
const [show, setShow] = useState(false)
return (
<>
<button id="show" onMouseEnter={loadSizeChart} onFocus={loadSizeChart} onClick={() => setShow(true)}>
Size chart
</button>
<Suspense fallback={fallback}>{show && <SizeChart />}</Suspense>
</>
)
}
Hover, wait a second, then click:
page mounted
import() called
size-chart.tsx evaluated
import() called
import() called
fallback shown
chart shown
The download started on hover and the module ran once, although import() was called three times (hover, focus on click, and lazy’s own first render): modules are cached by URL. But notice fallback shown: even with the code already downloaded, lazy’s first render suspended, because an import() promise never resolves synchronously. The fallback was brief, but it was there.
3. A transition
function PageTransition() {
const [show, setShow] = useState(false)
const [isPending, startTransition] = useTransition()
return (
<>
<button id="show" onMouseEnter={loadSizeChart} onFocus={loadSizeChart} onClick={() => startTransition(() => setShow(true))}>
Size chart {isPending && '⏳'}
</button>
<Suspense fallback={fallback}>{show && <SizeChart />}</Suspense>
</>
)
}
Clicking right away (the hover starts the download a moment before):
page mounted
import() called
import() called
pending ⏳ shown
import() called
size-chart.tsx evaluated
chart shown
No fallback at all. When an update inside startTransition suspends, React keeps showing the current UI and waits; isPending lets you show something small (here, ⏳ in the button).
Even with the code prefetched, the pending marker appeared briefly:
page mounted
import() called
size-chart.tsx evaluated
import() called
pending ⏳ shown
import() called
chart shown
To avoid that one-frame flicker of a pending indicator, libraries like spin-delay only show it if loading takes longer than a threshold (say 500 ms), and then keep it visible for a minimum time.
How it works
- The bundler splits at
import(). Vite or webpack emit the imported module, and everything only it depends on, as a separate file. lazycaches. It calls the loader on the first render only; the promise and the resulting component are cached, so later renders don’t load again.- Fallbacks follow update priority. If an urgent update (or the first mount) suspends, the nearest
Suspenseshows its fallback right away. If a transition suspends, React keeps the old UI. React also throttles reveals of suspended content (to about 300 ms) so a fast load doesn’t produce a quick flash of several states. - Where the boundary goes matters. The
Suspenseboundary decides what’s replaced by the fallback: put it close to the lazy component, not around the whole page.
Common mistakes
- Calling
lazyinside a component. A new component type every render. - A named export without the
.then((m) => ({ default: … }))adapter. - Splitting tiny components. Each chunk is an extra request; split features and routes, not buttons.
- No prefetch for likely actions. The user waits at the worst moment, right after they asked.
Interview Q&A
What is code splitting?
Splitting the JavaScript bundle so each screen downloads only the code it needs, loading the rest on demand with dynamic import(). Recorded: with a static import, the size chart’s code ran before the page mounted; with lazy, only after the click.
How does React.lazy work?
lazy(load) returns a component. On its first render it calls load(); until the promise resolves it suspends, so the nearest Suspense shows its fallback. The resolved module’s default export is the component, and both are cached.
Does calling import() several times download the module several times?
No. Modules are cached by URL. Recorded: three import() calls, one size-chart.tsx evaluated.
Why might the fallback still flash after prefetching?
lazy’s first render always suspends, because an import() promise can’t resolve synchronously, even from cache. Recorded: fallback shown after a prefetch that had finished.
How do you avoid the fallback?
Make the update a transition: startTransition(() => setShow(true)). React keeps the current UI while the new one suspends, and isPending tells you it’s waiting. Recorded: no fallback, just the ⏳ marker.
Related
- Concurrent Rendering: transitions and priorities.
- Error Boundaries: handle a chunk that fails to load.
Sources
- react.dev:
lazy,Suspense,useTransition - MDN:
import() - spin-delay: delaying pending indicators
- Topic order inspired by Kent C. Dodds’ EpicReact React Performance workshop; the example app and code here are this handbook’s own.
Primary source: react.dev/reference/react/lazy