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.
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:
- Entry point isolation. Each route or application entry maintains an independent dependency tree. Shared modules between entries are hoisted into vendor or shared chunks.
- Dynamic import boundaries. Every distinct
import()path creates a new chunk node. The bundler computes the dependency intersection to prevent duplication. - ESM spec compliance. Native dynamic imports return promises resolving to module namespaces. Bundlers must preserve the
__esModuleflag 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:
- Confirm the trigger. It manifests when output format is
es, a lazy route imports a CJS-only package, andoptimizeDepsdid not pre-bundle it during dev or Rollup skipped the transform during build. - 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. - Reproduce with
--debug. Runnpx vite build --debugand 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.
Related
- Code Splitting Strategies for Large Applications — the route- and vendor-boundary design these component patterns plug into.
- Fixing vendor chunk duplication with manualChunks — when React copies into multiple lazy chunks.
- Understanding ESM vs CommonJS in Modern Bundlers — the interop rules behind require-is-not-defined.
- Core Concepts of Modern Bundling — the dependency-graph model behind dynamic-import chunking.