skip to content

A React modal is loaded with React.lazy, so opening it shows a spinner for a beat. How do you make the chunk start downloading when the user hovers or focuses the button that opens it, and why does that not cause the module to load twice?

level: middleimportance: should knowfreq 38%

answer

  1. intent happens before the click
  2. the loader is a plain function
  3. who else may call it
  4. import() dedupes by specifier
  5. hover is not the only intent signal

basics

~20 s

Keep the loader in a named function, pass it to React.lazy, and also call it from the button's onMouseEnter and onFocus handlers. The module registry caches by specifier, so React's later call to the same loader resolves from cache instead of fetching again.

solid answer

~40 s

Give the loader a name instead of inlining it: `const loadModal = () => import('./Modal')`, then `const Modal = lazy(loadModal)`. Now attach `onMouseEnter={loadModal}` and `onFocus={loadModal}` to the trigger — hovering starts the download while the user is still moving the mouse, so by the time they click, the module is usually resident and the Suspense fallback never appears. There is no double fetch because `import()` is keyed by module specifier in the JavaScript module registry: the first call starts the request and every later call for the same specifier returns the same promise, resolved or still in flight. Pair it with focus so keyboard users get the same benefit, and remember to keep the Suspense boundary — preloading is an optimisation, not a guarantee.

code

javascript · 24 lines
javascript
import { lazy, Suspense, useState } from 'react';

const loadModal = () => import('./SettingsModal');
const SettingsModal = lazy(loadModal);

export function Toolbar() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button
        onMouseEnter={loadModal}
        onFocus={loadModal}
        onClick={() => setOpen(true)}
      >
        Settings
      </button>
      {open && (
        <Suspense fallback={<p>Loading…</p>}>
          <SettingsModal onClose={() => setOpen(false)} />
        </Suspense>
      )}
    </>
  );
}

go deeper

for a junior

Know that the function you pass to React.lazy is an ordinary function you can also call yourself, and that calling it early on hover starts the download before the user clicks.

for a middle

Explain that import() is keyed by module specifier so repeat calls reuse the same module record, and show the named-loader pattern wired to both onMouseEnter and onFocus.

for a senior

Weigh the bandwidth cost against the latency win: which triggers signal real intent, why blanket preloading competes with the current screen's requests, and why the Suspense boundary and error handling must survive the optimisation.

for a principal

Decide the policy — which routes and widgets are preloaded, on what signal, and how that is measured — so preloading is a deliberate, reviewable part of the loading strategy rather than sprinkled per component.

## The gap preloading closes A lazy split trades bundle size for a network round trip at the moment of use. The user clicks "Edit", React renders the lazy component, the loader fires, and only then does the chunk begin downloading. On a fast connection this is a flicker; on a slow one it is a visible stall right at the moment the user asked for something. Preloading on intent moves that round trip earlier, into the dead time before the click — hover, focus, a route becoming visible — so the code is already there when it is needed. ## The mechanism: the loader is just a function The key realisation is that `React.lazy` has no privileged relationship with the module. You hand it an ordinary function that returns a promise, and you can call that same function yourself: ```jsx import { lazy, Suspense, useState } from 'react'; const loadModal = () => import('./SettingsModal'); const SettingsModal = lazy(loadModal); function Toolbar() { const [open, setOpen] = useState(false); return ( <> <button onMouseEnter={loadModal} onFocus={loadModal} onClick={() => setOpen(true)} > Settings </button> {open && ( <Suspense fallback={<Spinner />}> <SettingsModal onClose={() => setOpen(false)} /> </Suspense> )} </> ); } ``` Hovering fires the loader; the chunk downloads in the background; clicking renders the lazy component, whose loader call now returns an already-settled promise. ## Why there is no second download Dynamic `import()` is resolved against the module registry, keyed by the resolved module specifier. The first call registers the module and starts fetching it. A second call for the same specifier does not restart anything: it hands back the same module record — the same in-flight promise if the load is still going, or an immediately resolved one if it has finished. Module evaluation likewise happens once. So calling the loader from a hover handler and letting React call it again on render is safe by construction, and it stays safe if the user hovers twenty times: every call after the first is essentially free. This is why the named-loader pattern is preferable to duplicating the import expression in two places. Two textually identical `import('./SettingsModal')` calls would in fact also dedupe, because deduplication is by specifier rather than by call site — but a single named function keeps the two uses from drifting apart when someone renames the file. ## Choosing the trigger - **`onMouseEnter`** is the classic one: the distance between hovering a control and clicking it typically buys a few hundred milliseconds. - **`onFocus`** matters for keyboard and assistive-technology users, who never generate a hover. Wiring only hover quietly gives those users the slower experience — an easy point to raise in an interview. - **`onPointerDown`** is the last-moment version, firing slightly before `click`; less lead time, but almost no wasted fetches. - **Idle or visibility triggers** suit route-level chunks: kick off the next likely route's loader once the current screen has settled, rather than tying it to a specific control. ## What it costs Preloading spends bandwidth on code the user might never reach. That is fine for a modal behind a prominent button and wasteful for every item in a long menu, especially on metered or slow connections. Trigger on real intent signals, not on mount of a whole navigation tree; a hundred hover-preloads racing each other also competes with the requests the current screen actually needs. ## Do not remove the boundary Preloading is opportunistic. A user can tab straight to a control and press Enter faster than the network responds, or the hover can happen a millisecond before the click on a slow link. The `<Suspense>` boundary and its fallback must stay — the preload simply makes the fallback rare rather than routine. For the same reason, error handling stays too: a preload that fails does not by itself surface anywhere, and the failure will show up when React renders the component. ## What interviewers listen for The insight being tested is that the loader is an ordinary function under your control, and that `import()` deduplicates by specifier. Candidates who instead invent a preload API, or claim a second call re-downloads the chunk, reveal they think of `lazy` as opaque machinery. Mentioning focus alongside hover, and being explicit that the Suspense boundary still has to exist, are the details that separate a complete answer from a half one.

  • Hover-based preloading gives keyboard users nothing. What do you add?
    Wire the same loader to `onFocus`, so tabbing to the control starts the download exactly as hovering does. `onPointerDown` is a useful complement for touch, where hover does not exist and the gap between pointer-down and the resulting click still buys a little time. The goal is that every input modality gets some lead time, not just the mouse.
  • Should you preload every lazy chunk as soon as the app becomes idle?
    Only where the payoff is likely. Idle-time preloading of one or two probable next routes is reasonable; preloading everything defeats the point of splitting, spends bandwidth on code most users never reach, and competes with requests the current screen still needs. Tie preloads to real intent signals or to a small, evidence-backed set of likely next destinations.
  • Can you drop the Suspense boundary once preloading is in place?
    No. Preloading is opportunistic — a keyboard user can activate the control before the chunk lands, and a slow connection can miss the window entirely. Without a boundary, that render fails outright. Keep the boundary and treat preloading as what it is: a way to make the fallback rare rather than a way to eliminate the suspended state.

saying these in an interview costs you the question

  • Claiming a second import() call downloads the chunk again
  • Inventing a React preload prop instead of calling the loader
  • Wiring hover only and forgetting keyboard focus
  • Removing the Suspense boundary because preloading exists
  • Preloading every chunk on mount, defeating the split

context