In React 19, how does React.lazy let a route component ship in its own chunk — what must the imported module expose, and what must be present in the tree when the lazy component first renders?
answer
- two APIs, one boundary
- the import call must be dynamic
- the module shape React expects
- default export, or reshape it
- nearest ancestor supplies the placeholder
basics
~20 sReact.lazy(() => import('./Page')) returns a component whose code loads on first render. The imported module must expose that component as its default export, and the lazy element must render inside a Suspense boundary that supplies a fallback.
solid answer
~40 s`React.lazy` takes a factory that returns a promise — normally `() => import('./Settings')` — and gives you back a component. Because the `import()` is inside a function, the bundler puts that module in a separate chunk that is only fetched the first time the component actually renders. Two contracts come with it. First, the resolved module must have a `default` export that is a React component; for a named export you adapt it with `import('./Chart').then(m => ({ default: m.Chart }))`. Second, the lazy element must have a `<Suspense>` ancestor, because while the chunk is in flight React suspends that subtree and renders the nearest boundary's `fallback` instead. Without a boundary React throws. The call to `lazy` itself belongs at module scope, not inside a component body.
go deeper
Be able to write the three lines from memory: the lazy constant at module scope, the dynamic import inside it, and a Suspense wrapper with a fallback. Say plainly that the module needs a default export.
Explain why the dynamic import() call form is what creates a separate chunk, how React calls the loader exactly once and caches the result, and how to adapt a named export into the { default: Component } shape React requires.
Show judgment about boundary placement — one fallback per route versus a tight boundary around a heavy widget — and about which modules are worth splitting at all, given that each split adds a network round trip at first render.
Own the strategy: which split points a codebase standardises on, how loading placeholders stay consistent across teams, and how split boundaries interact with the app's server-rendering and preloading story rather than being chosen ad hoc per component.
## The problem it solves A React app compiled into one bundle makes every user download every screen, including the admin panel they will never open and the rich text editor they use once a month. Code splitting breaks the bundle into chunks that are fetched on demand. React's built-in way to express a split point is `React.lazy` paired with `<Suspense>`: `lazy` marks *what* is split, `Suspense` says *what the user sees* while the split code is still travelling over the network. ## The API `lazy` takes one argument — a function (often called the loader or factory) that returns a promise — and returns a component you render like any other: ```jsx import { lazy, Suspense } from 'react'; const Settings = lazy(() => import('./routes/Settings')); function App() { return ( <Suspense fallback={<PageSkeleton />}> <Settings /> </Suspense> ); } ``` The crucial detail is that `import('./routes/Settings')` sits *inside* a function body. A static `import ... from` at the top of the file is resolved at build time and pulled into the current chunk; a call expression is a runtime decision, and every bundler treats it as a chunk boundary. React never fetches anything itself — it just calls your loader at the right moment and waits on the promise you hand back. React calls that loader **once**. It stores the settled result on the lazy component, so a second render of `<Settings />` uses the already-loaded module with no further work. ## The default-export contract The promise must resolve to an object with a `default` property holding a component. That is exactly the shape an ES module namespace object has when the file does `export default function Settings() {…}`, which is why the plain form works with no ceremony. If your component is a named export, reshape the result yourself: ```js const Chart = lazy(() => import('./Chart').then((m) => ({ default: m.Chart })) ); ``` The same trick is how people load one component out of a barrel file, though pointing the loader straight at the real module is better — a barrel drags its siblings into the chunk. ## Why Suspense is mandatory While the loader's promise is pending, the lazy component has nothing to render, so React *suspends*: it walks up from that position to the nearest `<Suspense>` ancestor and renders that boundary's `fallback` in place of its children. When the promise resolves, React re-renders and swaps the real content in. If there is no `<Suspense>` anywhere above, React has nowhere to put a placeholder and the render fails with an error. The boundary does not have to be the immediate parent — any ancestor works, and the *nearest* one wins. One boundary can cover several lazy components, in which case the fallback stays until all of them have loaded. That is a placement decision: a boundary wrapping the whole page turns the entire screen into a skeleton, while a boundary wrapping only a lazy chart keeps the rest of the page interactive. ## Where the `lazy` call goes Declare it at module scope. Calling `lazy` inside a component body produces a brand-new component type on every render, which makes React unmount and remount the subtree instead of reusing it. The idiomatic file has the `lazy` constants next to the imports. ## What it is not - It is not a data-loading tool. `lazy` splits *code*; fetching the data the component needs is a separate concern. - It does not work for anything but components. You cannot `lazy` a hook or a utility function — for those, call `import()` yourself where you need it. - It does not change *when the component renders*, only when its code arrives. The first render of a lazy route still blocks on a network round trip, which is why hover- or focus-triggered preloading is a common companion technique. ## React 19 notes `lazy` is unchanged in React 19 and remains the API for component-level splitting; `use()` reads a promise's *value* inside a render but is not a replacement for a split boundary. Suspense also drives streaming server rendering, so on a server-rendered app the same boundary that shows your fallback in the browser is the unit the server can stream around — but that is the rendering model's concern, not the split point's. ## What interviewers listen for They want to hear the three moving parts named without prompting: the loader returns a promise, the module resolves with a `default` component, and a `Suspense` ancestor supplies the fallback. Candidates who can also say *why* the dynamic call form is what creates the chunk — rather than treating `lazy` as magic that talks to the bundler — are visibly a tier above.
- Your component is exported as `export function Chart()` with no default export — can you still use React.lazy on it?Yes, but you have to hand React the shape it wants: `lazy(() => import('./Chart').then(m => ({ default: m.Chart })))`. React only ever looks at the `default` property of the resolved object, so any promise that settles with `{ default: SomeComponent }` works. Point the import at the real module rather than a barrel file, otherwise the barrel's other exports end up in the chunk.
- Does the Suspense boundary have to be the direct parent of the lazy component?No. React walks up from the suspended component and uses the nearest `<Suspense>` ancestor, wherever it is. One boundary can cover several lazy components, and then its fallback stays visible until all of them have resolved. Placement is a UX decision: a boundary high in the tree replaces a whole screen, a boundary right around the lazy part keeps the surrounding UI on screen and interactive.
- What happens if you render a lazy component with no Suspense boundary anywhere above it?React has nowhere to render a placeholder, so the render fails and the error propagates as an unhandled error — you get a crash rather than a silent blank area. This is why the boundary is described as a requirement of `lazy` rather than an optional nicety. In practice teams put a boundary at the route level so every lazily loaded screen inherits one.
saying these in an interview costs you the question
- Thinking React.lazy fetches the chunk itself instead of calling your loader
- Saying a static import at the top can be lazily loaded
- Assuming named exports work without reshaping to default
- Believing Suspense is optional and React just renders nothing
- Calling React.lazy inside the component that renders it