Dynamic import() Code Splitting Patterns for React

This guide shows how import() expressions become deterministic chunks in a React app, and how to wire React.lazy and Suspense so lazy routes load without waterfalls or require is not defined crashes. It sits under Code Splitting Strategies for Large Applications, which covers the broader vendor- and route-boundary design that these component-level patterns plug into.

The native import() expression, standardized in ECMAScript 2020, is the primary mechanism for on-demand module loading. Paired with React’s lazy and Suspense, it dictates how an application partitions execution contexts, manages network waterfalls, and protects Time to Interactive. We examine how Vite, Rollup, and esbuild translate import() calls into chunk graphs, resolve CommonJS/ESM interop failures, and enforce reproducible splitting.

React.lazy import resolution through a Suspense boundary A React.lazy call triggers a dynamic import that fetches a chunk; Suspense shows a fallback until the promise resolves to a default export. React.lazy(() => import('./Page')) Suspense fallback shown while pending fetch chunk Page-[hash].js resolve { default } mount component named export? map .then(m => ({ default: m.Page }))
Figure: a React.lazy dynamic import resolves to a default export through a Suspense boundary; named exports must be mapped explicitly.

Prerequisites & Reproducible Setup

# Vite 5.x / React 18, Node 20+
npm create vite@latest split-demo -- --template react-ts
cd split-demo && npm i
npm i react-router-dom@6
npm i -D rollup-plugin-visualizer@5

This assumes React 18+ (for Suspense on the client and streaming SSR), Vite 5.x or 6.x, and Rollup 4.x for the production build.

AST Transformation & Chunk Graph Generation

Modern bundlers do not defer chunk boundary decisions to runtime. They perform static analysis on the Abstract Syntax Tree during the build to construct a dependency graph that dictates module partitioning. When the parser encounters import('./Module'), it marks the target and its transitive dependencies as a split point.

As detailed in Core Concepts of Modern Bundling, the shift from runtime require() resolution to compile-time ESM resolution changes how chunk graphs are constructed. Rollup and Vite traverse the AST for static string literals inside import() calls. If the path is fully resolvable at build time, the bundler generates a deterministic chunk id, computes a hash, and emits a separate file. If the path contains dynamic interpolation (e.g. import(`./locales/${lang}.js`)), the bundler falls back to glob-based resolution or warns, depending on strictness.

Chunk graph topology is governed by three constraints:

  1. Entry point isolation. Each route or application entry maintains an independent dependency tree. Shared modules between entries are hoisted into vendor or shared chunks.
  2. Dynamic import boundaries. Every distinct import() path creates a new chunk node. The bundler computes the dependency intersection to prevent duplication.
  3. ESM spec compliance. Native dynamic imports return promises resolving to module namespaces. Bundlers must preserve the __esModule flag and default-export semantics so React components resolve correctly.

Diagnosis Workflow: Uncaught ReferenceError: require is not defined

A frequent failure in ESM-first builds is:

Uncaught ReferenceError: require is not defined at __require (chunk-vendor.js:12)

This occurs when a bundler emits an ESM-only chunk but inlines a CommonJS dependency that relies on require. With output.format: 'es', the bundler strips Node-style polyfills. If a dynamically imported package (legacy lodash, moment, an unmaintained UI library) exports via module.exports or calls require() internally, the chunk executes in a browser where require is undefined.

Work the diagnosis in order:

  1. Confirm the trigger. It manifests when output format is es, a lazy route imports a CJS-only package, and optimizeDeps did not pre-bundle it during dev or Rollup skipped the transform during build.
  2. Audit the dependency manifest. Inspect the package’s package.json. A "type": "module" or a modern "exports" field means ESM. If only "main" points to a non-ESM .js, the package is CJS-only.
  3. Reproduce with --debug. Run npx vite build --debug and grep the log for the offending module to confirm it reached the ESM output untransformed.

The Solution Config

A single annotated config covers both the interop fix and deterministic chunk naming.

// vite.config.ts — Vite 5.x / Rollup 4.x
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import commonjs from '@rollup/plugin-commonjs';

export default defineConfig({
  plugins: [
    react(),
    // Transform mixed/CJS modules that slip past Vite's pre-bundling.
    commonjs({
      transformMixedEsModules: true, // handle files mixing CJS + ESM
      requireReturnsDefault: 'auto',  // require('pkg') -> correct default
      include: /node_modules\/(legacy-package|moment)/,
    }),
  ],
  // Pre-bundle CJS deps to ESM during dev so require() never reaches the browser.
  optimizeDeps: { include: ['legacy-package', 'moment'] },
  build: {
    rollupOptions: {
      output: {
        format: 'es',
        chunkFileNames: 'chunks/[name]-[hash].js',
        entryFileNames: 'assets/[name]-[hash].js',
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('lodash') || id.includes('moment')) return 'vendor-legacy';
            if (id.includes('react-router')) return 'vendor-routing';
            return 'vendor-core';
          }
          // Route-level splitting keyed on the routes directory.
          const m = id.match(/\/src\/routes\/([^/]+)/);
          return m ? `route-${m[1]}` : undefined;
        },
      },
    },
  },
});

transformMixedEsModules: true forces transformation even when ESM syntax sits alongside CJS. requireReturnsDefault: 'auto' resolves require('pkg') to the correct default without breaking namespace compatibility. For the deeper interop rules, see Understanding ESM vs CommonJS in Modern Bundlers.

React.lazy with default and named exports

Dynamic imports must resolve to a module with a default export. Named exports require an explicit map:

import React, { Suspense } from 'react';

// Named export needs an explicit mapping to { default }.
const Dashboard = React.lazy(() =>
  import('./routes/Dashboard').then((m) => ({ default: m.Dashboard }))
);

// Default export needs no mapping.
const Settings = React.lazy(() => import('./routes/Settings'));

export const AppRoutes = () => (
  <Suspense fallback={<div className="skeleton">Loading…</div>}>
    <Dashboard />
    <Settings />
  </Suspense>
);

Rollup (graph-aware shared chunk)

// rollup.config.js — Rollup 4.x
export default {
  input: 'src/main.tsx',
  output: {
    dir: 'dist',
    format: 'es',
    manualChunks(id, { getModuleInfo }) {
      const info = getModuleInfo(id);
      // Prevent shared utilities from duplicating across lazy chunks.
      if (id.includes('/src/utils/') && info && info.importers.length > 2) {
        return 'shared-utils';
      }
      return null;
    },
  },
};

esbuild

esbuild has no function-based manualChunks, but supports deterministic naming for ESM splitting:

# esbuild 0.25.x, Node 20+
esbuild src/main.tsx --bundle --splitting --outdir=dist \
  --chunk-names='chunks/[name]-[hash]' --format=esm --minify

Verification

Add the visualizer and rebuild to confirm chunk boundaries match intent:

// vite.config.ts — verification only
import { visualizer } from 'rollup-plugin-visualizer';
// add to plugins:
visualizer({ template: 'treemap', gzipSize: true, filename: 'dist/stats.html' });

In dist/stats.html, confirm each lazy route is its own chunk, no duplicate dependencies span lazy chunks, and no wildcard import pulled an unexpected module in. Then capture the network waterfall under npx vite preview with throttling: route chunks should fetch in parallel, not serialize.

Gotchas & Edge Cases

Waterfalls from nested dynamic imports

A lazy component that itself triggers a dynamic import() during first render serializes two round-trips. Flatten the tree, and preload anticipated routes with <link rel="modulepreload" href="/chunks/route-dashboard-[hash].js"> in the head.

Hydration mismatches in SSR/SSG

When server-rendered HTML expects a component that loads asynchronously on the client, checksums mismatch. Wrap lazy components in identical Suspense boundaries on both client and server, and use renderToPipeableStream (React 18) to flush HTML before async chunks resolve.

StrictMode double-invocation

React’s dev mode mounts components twice, which can make a lazy chunk appear to fetch twice. Validate fetch counts in a production build where double-mounting is disabled.

Over-splitting

Splitting a 50 KB module into five 10 KB chunks rarely helps on modern networks. Keep entry chunks under ~170 KB compressed, limit concurrent dynamic requests to 4–6 per transition, and keep chunk hashes stable across builds so CDN caching holds. For vendor-boundary design at the application scale, see Code Splitting Strategies for Large Applications.