ReactReact.lazy & Dynamic import()

React.lazy & Dynamic import()

import() is a JavaScript language feature — not React-specific — that loads a module asynchronously at runtime and returns a Promise. React builds its lazy() API on top of it. Understanding both layers lets you use them beyond just splitting React components: loading polyfills on demand, feature-flagging heavy libraries, or bulk-importing files by pattern.

Dynamic import() Deep Dive

Static import statements run at parse time — they are hoisted to the top of the module and always evaluated. Dynamic import() is a function call that returns a Promise. The module is fetched and evaluated only when the import() expression is reached at runtime:

JS
// Static import — loaded immediately when the bundle executes
import { format } from 'date-fns'

// Dynamic import — loaded on demand, returns a Promise
async function formatDate(date) {
  // date-fns is only fetched the first time formatDate() is called
  const { format } = await import('date-fns')
  return format(date, 'yyyy-MM-dd')
}

// The Promise resolves to the module namespace object
import('./heavy-module').then(module => {
  module.default()     // access the default export
  module.namedFn()     // access a named export
})
Note
Browsers cache the result of import(). Calling import('./Foo') a second time for the same path returns the same resolved module — no extra network request.
Loading Non-Component Modules Dynamically

import() shines for large libraries that are only needed in specific paths. Load them only when the user triggers the feature:

JS
// ── Polyfill only when needed ──────────────────────────────────────
async function initSmoothScroll() {
  if (!('scrollBehavior' in document.documentElement.style)) {
    // Only load the polyfill on browsers that need it
    await import('smoothscroll-polyfill').then(m => m.default.polyfill())
  }
}

// ── Feature flag: only load analytics for opted-in users ────────────
async function initAnalytics(user) {
  if (!user.analyticsEnabled) return
  const { trackEvent } = await import('./analytics')
  trackEvent('page_view', { userId: user.id })
}

// ── Heavy PDF generator — only when user clicks Export ──────────────
async function handleExport(data) {
  const { generatePDF } = await import('./pdfGenerator')  // ~180 KB
  const blob = await generatePDF(data)
  downloadBlob(blob, 'report.pdf')
}
React.lazy Builds on import()

React.lazy is a thin wrapper that integrates import() with React's rendering cycle. It accepts a factory function that returns a Promise for a module with a default export that is a React component:

JSX
import { lazy, Suspense } from 'react'

// lazy() stores the Promise internally.
// On first render it throws the Promise (React catches it → shows Suspense fallback).
// When the Promise resolves, React re-renders with the real component.
const UserDashboard = lazy(() => import('./UserDashboard'))

// ── Named export? Create a re-export ────────────────────────────────
// In src/charts/LineChart.reexport.ts:
//   export { LineChart as default } from './LineChart'
const LineChart = lazy(() => import('./charts/LineChart.reexport'))

// ── Or inline with .then() ───────────────────────────────────────────
const LineChart2 = lazy(() =>
  import('./charts').then(module => ({ default: module.LineChart }))
)

function App() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <UserDashboard />
    </Suspense>
  )
}
Warning
React.lazy requires a default export. If the target module only has named exports, you must create a wrapper or use the .then(module => ({ default: module.Named })) pattern — otherwise you'll get a runtime error.
Preloading: Trigger import() Before the Click

Because import() returns a cached Promise on repeated calls, you can call it speculatively to warm the cache. The component renders immediately when the user actually clicks because the module is already downloaded:

JSX
import { lazy, Suspense, useState } from 'react'

const SettingsPanel = lazy(() => import('./SettingsPanel'))

// Calling this starts the download immediately — React.lazy will receive
// the same resolved Promise when SettingsPanel first renders
function preload() {
  import('./SettingsPanel')
}

function Header() {
  const [open, setOpen] = useState(false)

  return (
    <>
      <button
        onMouseEnter={preload}   // start download on hover (~200ms head start)
        onFocus={preload}         // keyboard users too
        onClick={() => setOpen(true)}
      >
        Open Settings
      </button>

      {open && (
        <Suspense fallback={<p>Loading…</p>}>
          <SettingsPanel onClose={() => setOpen(false)} />
        </Suspense>
      )}
    </>
  )
}
Multiple Lazy Imports in Parallel

When several lazy components mount simultaneously, their fetches run in parallel. You don't need to do anything special — the browser handles concurrent requests. But you can also trigger parallel preloads programmatically:

JSX
import { lazy, Suspense } from 'react'

const ChartA = lazy(() => import('./ChartA'))
const ChartB = lazy(() => import('./ChartB'))
const ChartC = lazy(() => import('./ChartC'))

// All three chunks start downloading in parallel when Dashboard mounts.
// Each one shows its own fallback independently.
function Dashboard() {
  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)' }}>
      <Suspense fallback={<Skeleton />}><ChartA /></Suspense>
      <Suspense fallback={<Skeleton />}><ChartB /></Suspense>
      <Suspense fallback={<Skeleton />}><ChartC /></Suspense>
    </div>
  )
}

// Manual parallel preload — e.g. on route change start
function preloadDashboard() {
  Promise.all([
    import('./ChartA'),
    import('./ChartB'),
    import('./ChartC'),
  ])
}
Error Handling with Retry

A chunk download can fail (flaky connection, CDN hiccup). Wrap the factory in a retry loop to recover automatically:

JSX
import { lazy } from 'react'

function lazyWithRetry(factory, retries = 3, delay = 500) {
  return lazy(() =>
    new Promise((resolve, reject) => {
      const attempt = (remaining) => {
        factory()
          .then(resolve)
          .catch((err) => {
            if (remaining === 0) {
              reject(err)
            } else {
              setTimeout(() => attempt(remaining - 1), delay)
            }
          })
      }
      attempt(retries)
    })
  )
}

const HeavyWidget = lazyWithRetry(() => import('./HeavyWidget'))
Vite's import.meta.glob

Vite extends import() with import.meta.glob — a compile-time pattern that creates a map of lazy-import functions for all matching files. Useful for plugin systems, icon sets, or dynamic route loaders:

JSX
// import.meta.glob returns an object: { './icons/StarIcon.tsx': () => import(...) }
const icons = import.meta.glob('./icons/*.tsx')

async function loadIcon(name) {
  const loader = icons[`./icons/${name}.tsx`]
  if (!loader) throw new Error(`Icon not found: ${name}`)
  const module = await loader()
  return module.default
}

// Eager loading — imports all at build time (no lazy)
const allIcons = import.meta.glob('./icons/*.tsx', { eager: true })

// With React.lazy: auto-generate lazy components for all page files
const pages = import.meta.glob('./pages/*.tsx')
const routes = Object.entries(pages).map(([path, loader]) => ({
  path: path.replace('./pages/', '/').replace('.tsx', ''),
  Component: lazy(loader),
}))
  • import() is a JavaScript language feature that returns a Promise for a module namespace object.

  • React.lazy wraps import() to integrate it with React rendering — the component suspends until the Promise resolves.

  • Both only require the module to have a default export when used with React.lazy.

  • Repeated calls to import() for the same path return the cached Promise — no extra requests.

  • Preload by calling import() on hover/focus before the user clicks.

  • For bulk lazy loading by file pattern, use Vite's import.meta.glob.

Tip
Dynamic import() is supported in all modern browsers natively. You don't need any Babel plugin or special setup — just use it. Bundlers like webpack and Vite automatically detect import() calls and create separate chunks at build time.