Why Modules Exist
Before modules, all JavaScript loaded into a single global scope. Every variable, every function, every library — all shared one namespace.
<!-- The old days -->
<script src="jquery.js"></script>
<script src="lodash.js"></script>
<script src="utils.js"></script>
<script src="app.js"></script>
<!-- Any script can overwrite variables from any other script -->
Problems with this approach:
- Naming collisions — two libraries defining the same global variable
- Implicit dependencies — no way to know which scripts depend on which
- Load order matters — scripts must appear in the correct sequence
- No encapsulation — all internal details are exposed globally
- No lazy loading — everything loads upfront
Modules solve all of these problems.
ES Modules (ESM)
ES Modules are the official JavaScript module system, standardized in ES2015 and supported in all modern browsers and Node.js.
Basic Export and Import
// math.js — exporting
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export const PI = 3.14159;
// app.js — importing
import { add, subtract, PI } from './math.js';
console.log(add(2, 3)); // 5
console.log(subtract(10, 4)); // 6
console.log(PI); // 3.14159
Using in the Browser
<!-- type="module" enables ES module syntax -->
<script type="module" src="app.js"></script>
<!-- Or inline -->
<script type="module">
import { add } from './math.js';
console.log(add(1, 2));
</script>
Module scripts differ from regular scripts:
Feature <script> <script type="module">
────────────────────────────────────────────────────────────────────
Scope global module (isolated)
Strict mode opt-in always on
Top-level this window undefined
Runs immediately deferred (after DOM)
Loaded synchronously asynchronously
Duplicate loads runs each time runs once (cached)
CORS not required required
Named Exports
Named exports let you export multiple values from a module. Each exported value has a name, and importers must use that exact name (or rename it).
Export Styles
// Style 1: Inline export (preferred)
export function formatDate(date) {
return date.toISOString().split('T')[0];
}
export const MAX_RETRIES = 3;
export class Logger {
log(msg) { console.log(msg); }
}
// Style 2: Export list (all at once)
function formatDate(date) {
return date.toISOString().split('T')[0];
}
const MAX_RETRIES = 3;
class Logger {
log(msg) { console.log(msg); }
}
export { formatDate, MAX_RETRIES, Logger };
Import Styles
// Import specific names
import { formatDate, MAX_RETRIES } from './utils.js';
// Rename on import
import { formatDate as fmtDate, Logger as AppLogger } from './utils.js';
console.log(fmtDate(new Date()));
// Import everything as a namespace
import * as utils from './utils.js';
console.log(utils.formatDate(new Date()));
console.log(utils.MAX_RETRIES);
Renaming Exports
// Rename on export
function internalHelper() { /* ... */ }
export { internalHelper as publicHelper };
// Consumer uses the public name
import { publicHelper } from './module.js';
Default Exports
Each module can have one default export. It represents the "main" thing the module provides.
// Logger.js — default export
export default class Logger {
constructor(prefix) {
this.prefix = prefix;
}
log(msg) {
console.log(`[${this.prefix}] ${msg}`);
}
error(msg) {
console.error(`[${this.prefix}] ERROR: ${msg}`);
}
}
// app.js — import default (any name works)
import Logger from './Logger.js';
import MyLogger from './Logger.js'; // same thing, different local name
const log = new Logger('App');
log.log('Started'); // [App] Started
Default + Named Exports Together
// api.js
export default class ApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
async get(path) {
const res = await fetch(`${this.baseUrl}${path}`);
return res.json();
}
}
export const DEFAULT_BASE_URL = 'https://api.example.com';
export const TIMEOUT = 5000;
// Import both
import ApiClient, { DEFAULT_BASE_URL, TIMEOUT } from './api.js';
const client = new ApiClient(DEFAULT_BASE_URL);
Named vs Default — Comparison
Aspect Named Export Default Export
─────────────────────────────────────────────────────────────────
Count per module unlimited one
Import syntax { name } anyName
Refactoring IDE can auto-rename name can diverge
Discovery auto-complete works must know the module
Convention utilities, constants primary class/function
Named vs Default — When to Use
// Use DEFAULT when the module has one primary purpose
// user.js
export default class User { /* ... */ }
// Use NAMED when the module exports multiple related items
// validators.js
export function isEmail(str) { /* ... */ }
export function isPhone(str) { /* ... */ }
export function isURL(str) { /* ... */ }
// Use BOTH when there is one primary + supporting items
// config.js
export default function loadConfig() { /* ... */ }
export const DEFAULT_PORT = 3000;
export const DEFAULT_HOST = 'localhost';
Re-exports
Modules can re-export values from other modules, useful for creating barrel files (index files).
// components/Button.js
export function Button(props) { /* ... */ }
// components/Input.js
export function Input(props) { /* ... */ }
// components/Modal.js
export default function Modal(props) { /* ... */ }
// components/index.js — barrel file (re-exports everything)
export { Button } from './Button.js';
export { Input } from './Input.js';
export { default as Modal } from './Modal.js';
// Can also re-export everything
export * from './Button.js';
export * from './Input.js';
// Consumer — clean imports from one path
import { Button, Input, Modal } from './components/index.js';
CommonJS (CJS)
CommonJS is Node.js's original module system. It uses require() and module.exports.
// math.js — CommonJS exports
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
// Export individual values
module.exports.add = add;
module.exports.subtract = subtract;
// Or shorthand
exports.add = add;
exports.subtract = subtract;
// Or export an object
module.exports = { add, subtract };
// app.js — CommonJS require
const { add, subtract } = require('./math.js');
const math = require('./math.js');
console.log(add(2, 3)); // 5
console.log(math.subtract(5, 2)); // 3
ESM vs CommonJS — Comparison
Feature ES Modules (ESM) CommonJS (CJS)
─────────────────────────────────────────────────────────────────
Syntax import/export require/module.exports
Loading async (static) synchronous
Parse time before execution at runtime
Top-level await yes no
Tree shaking yes limited
Browser support native needs bundler
Node.js .mjs or "type":"module" .cjs or default
Strict mode always opt-in
Live bindings yes no (copies)
Conditional imports dynamic import() yes (require in if)
Live Bindings vs Copies
This is a subtle but important difference.
// ESM — live bindings (exports reflect latest value)
// counter.mjs
export let count = 0;
export function increment() {
count++;
}
// app.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1 (live binding — sees the update)
// CJS — copies (exports are snapshots)
// counter.js
let count = 0;
function increment() {
count++;
}
module.exports = { count, increment };
// app.js
const { count, increment } = require('./counter.js');
console.log(count); // 0
increment();
console.log(count); // 0 (copy — does NOT see the update)
Dynamic Imports
Static import declarations must be at the top level. Dynamic import() returns a Promise and can be used anywhere, including inside conditionals and loops.
// Static import — always loaded
import { heavyChart } from './charts.js';
// Dynamic import — loaded on demand
async function showChart(type) {
if (type === 'bar') {
const { BarChart } = await import('./charts/BarChart.js');
return new BarChart();
} else if (type === 'pie') {
const { PieChart } = await import('./charts/PieChart.js');
return new PieChart();
}
}
Code Splitting
Dynamic imports enable code splitting — loading code only when needed.
// Route-based code splitting
const routes = {
'/': () => import('./pages/Home.js'),
'/about': () => import('./pages/About.js'),
'/dashboard': () => import('./pages/Dashboard.js'),
};
async function navigate(path) {
const loadPage = routes[path];
if (!loadPage) {
const { NotFound } = await import('./pages/NotFound.js');
return new NotFound();
}
const module = await loadPage();
return new module.default();
}
Loading Default and Named Exports
// Default export
const module = await import('./Logger.js');
const Logger = module.default;
// Named exports
const { add, subtract } = await import('./math.js');
// Both
const { default: ApiClient, TIMEOUT } = await import('./api.js');
Dynamic Import Error Handling
async function loadPlugin(name) {
try {
const plugin = await import(`./plugins/${name}.js`);
plugin.default.init();
} catch (error) {
if (error.message.includes('Cannot find module')) {
console.warn(`Plugin "${name}" not found, skipping`);
} else {
throw error;
}
}
}
Module Patterns
Before ES Modules, developers used patterns to achieve encapsulation.
IIFE (Immediately Invoked Function Expression)
// Creates a private scope — the original module pattern
const Calculator = (function () {
// Private
let history = [];
function addToHistory(operation) {
history.push(operation);
}
// Public (returned object)
return {
add(a, b) {
const result = a + b;
addToHistory(`${a} + ${b} = ${result}`);
return result;
},
subtract(a, b) {
const result = a - b;
addToHistory(`${a} - ${b} = ${result}`);
return result;
},
getHistory() {
return [...history]; // return copy
},
};
})();
Calculator.add(2, 3); // 5
Calculator.getHistory(); // ['2 + 3 = 5']
// Calculator.history // undefined (private)
// Calculator.addToHistory // undefined (private)
Revealing Module Pattern
A variation where you define everything privately, then reveal only what you want.
const UserService = (function () {
// All private
const users = [];
function validate(user) {
return user.name && user.email;
}
function create(user) {
if (!validate(user)) {
throw new Error('Invalid user');
}
users.push(user);
return user;
}
function findById(id) {
return users.find((u) => u.id === id);
}
function getAll() {
return [...users];
}
// Reveal public API
return {
create,
findById,
getAll,
// validate is NOT exposed
};
})();
These patterns are mostly historical now — ES Modules provide native encapsulation. But you will see them in older codebases and libraries.
Tree Shaking
Tree shaking is the process of eliminating dead (unused) code from bundles. It relies on the static structure of ES Modules.
// utils.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function multiply(a, b) { return a * b; }
export function divide(a, b) { return a / b; }
// plus 50 more functions...
// app.js — only imports add
import { add } from './utils.js';
console.log(add(1, 2));
With tree shaking, the bundler (webpack, Rollup, esbuild) sees that only add is used and removes subtract, multiply, divide, and all 50 other functions from the final bundle.
Why ESM Enables Tree Shaking
ES Modules (static):
- imports/exports are determined at parse time
- bundler can analyze the dependency graph
- unused exports are safely removed
CommonJS (dynamic):
- require() can be called conditionally
- exports can be computed at runtime
- bundler cannot safely determine what is used
// ESM — bundler knows exactly what is used
import { add } from './math.js'; // static, analyzable
// CJS — bundler cannot be sure what is used
const math = require('./math.js'); // entire module loaded
const fn = math[someVariable]; // dynamic access — cannot tree shake
Writing Tree-Shakeable Code
// GOOD — named exports, tree-shakeable
export function formatDate(date) { /* ... */ }
export function formatCurrency(amount) { /* ... */ }
export function formatPercent(value) { /* ... */ }
// BAD — default object export, NOT tree-shakeable
export default {
formatDate(date) { /* ... */ },
formatCurrency(amount) { /* ... */ },
formatPercent(value) { /* ... */ },
};
// BAD — class with methods, NOT tree-shakeable
export default class Formatter {
formatDate(date) { /* ... */ }
formatCurrency(amount) { /* ... */ }
formatPercent(value) { /* ... */ }
}
Side Effects
A module has side effects if importing it changes global state.
// HAS side effects — importing this changes global state
import './polyfills.js'; // patches Array.prototype
import './analytics.js'; // starts tracking
// NO side effects — only exports values
import { add } from './math.js';
In package.json, you can declare that your package is side-effect-free:
{
"name": "my-utils",
"sideEffects": false
}
Or specify which files have side effects:
{
"sideEffects": ["./src/polyfills.js", "*.css"]
}
This helps bundlers aggressively tree-shake your code.
Circular Dependencies
A circular dependency occurs when module A imports from module B, and module B imports from module A.
// a.js
import { b } from './b.js';
export const a = `a depends on ${b}`;
// b.js
import { a } from './a.js';
export const b = `b depends on ${a}`;
How ESM Handles Circular Dependencies
ES Modules handle circulars through live bindings, but the values may be uninitialized when first accessed.
// parent.js
import { childValue } from './child.js';
export const parentValue = 'parent';
console.log('parent sees child:', childValue);
// child.js
import { parentValue } from './parent.js';
export const childValue = 'child';
console.log('child sees parent:', parentValue);
The output depends on which module is the entry point:
If parent.js is entry:
1. parent.js starts loading, sees import from child.js
2. child.js starts executing
3. child.js tries to read parentValue -> undefined (not yet initialized)
4. child.js finishes: "child sees parent: undefined"
5. parent.js finishes: "parent sees child: child"
Avoiding Circular Dependencies
// Strategy 1: Extract shared code into a third module
// shared.js (no imports from a.js or b.js)
export const sharedConfig = { /* ... */ };
// a.js
import { sharedConfig } from './shared.js';
// b.js
import { sharedConfig } from './shared.js';
// Strategy 2: Use dependency injection
// Instead of importing directly, accept dependencies as parameters
// bad — circular
// userService.js
import { orderService } from './orderService.js';
export function getUserOrders(userId) {
return orderService.getByUserId(userId);
}
// good — injection
// userService.js
export function createUserService(orderService) {
return {
getUserOrders(userId) {
return orderService.getByUserId(userId);
},
};
}
// Strategy 3: Lazy imports with dynamic import()
// a.js
export async function doSomething() {
const { helperFromB } = await import('./b.js');
return helperFromB();
}
Detecting Circular Dependencies
Circular dependencies are often a sign of poor module architecture. Use tools to detect them:
# madge — dependency graph tool
npx madge --circular src/
# eslint plugin
# .eslintrc
# { "rules": { "import/no-cycle": "error" } }
Module Best Practices
1. One Purpose Per Module
// BAD — does too many things
// utils.js
export function formatDate() { /* ... */ }
export function fetchUser() { /* ... */ }
export function validateEmail() { /* ... */ }
export function sortArray() { /* ... */ }
// GOOD — focused modules
// formatters.js
export function formatDate() { /* ... */ }
export function formatCurrency() { /* ... */ }
// validators.js
export function validateEmail() { /* ... */ }
export function validatePhone() { /* ... */ }
2. Prefer Named Exports
// Enables tree shaking and IDE auto-imports
export function processOrder(order) { /* ... */ }
export function validateOrder(order) { /* ... */ }
export function formatOrder(order) { /* ... */ }
3. Use Barrel Files Thoughtfully
// features/auth/index.js
export { LoginForm } from './LoginForm.js';
export { useAuth } from './useAuth.js';
export { AuthProvider } from './AuthProvider.js';
// Consumer
import { LoginForm, useAuth } from './features/auth';
But beware — barrel files can hurt tree shaking if they re-export everything:
// BAD — importing one thing loads all components
export * from './HeavyChart.js';
export * from './DataTable.js';
export * from './Map3D.js';
// If consumer imports only HeavyChart, DataTable and Map3D may still be bundled
// GOOD — import directly for better tree shaking
import { HeavyChart } from './features/charts/HeavyChart.js';
4. Avoid Side Effects at Module Level
// BAD — side effect on import
let instance;
export function getInstance() {
return instance;
}
instance = createConnection(); // runs when module is imported
// GOOD — explicit initialization
let instance;
export function initialize() {
instance = createConnection();
}
export function getInstance() {
if (!instance) throw new Error('Not initialized');
return instance;
}
5. Keep Import Sections Organized
// External/third-party imports
import React from 'react';
import { useQuery } from '@tanstack/react-query';
// Internal absolute imports
import { Button } from '@/components/Button';
import { useAuth } from '@/hooks/useAuth';
// Relative imports
import { validateForm } from './validators';
import { FORM_FIELDS } from './constants';
// Type imports
import type { FormData, UserProfile } from './types';
// Style imports
import './styles.css';
6. Use package.json "exports" Field
Control what consumers can import from your package:
{
"name": "my-library",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
},
"./utils": {
"import": "./dist/esm/utils.js",
"require": "./dist/cjs/utils.js"
}
}
}
// Consumers can import from defined entry points
import { something } from 'my-library';
import { helper } from 'my-library/utils';
// But NOT from internal paths
// import { internal } from 'my-library/dist/esm/internal.js'; // error
Node.js Module Resolution
Node.js determines whether to use ESM or CJS based on several signals:
Signal Module System
────────────────────────────────────────────────────────
.mjs extension ESM
.cjs extension CJS
.js + "type": "module" in package.json ESM
.js + no "type" field CJS
.js + "type": "commonjs" CJS
// package.json — opt entire project into ESM
{
"type": "module"
}
// With "type": "module", .js files use ESM syntax
import { something } from './module.js'; // ESM
// Use .cjs extension for CommonJS files in an ESM project
// legacy.cjs
const fs = require('fs'); // CJS
Module Loading Lifecycle
Understanding how modules load helps debug timing issues.
Static Import Lifecycle:
1. Parse Module source is parsed to find import/export statements
2. Instantiate Module graph is built, bindings are created (but not initialized)
3. Evaluate Code runs top to bottom, bindings get their values
Entry ──parse──> A ──parse──> B ──parse──> C
│
C evaluates first <────────────────────────┘
B evaluates second
A evaluates last
Entry evaluates last
Dynamic Import Lifecycle:
1. import() is called at runtime
2. Module is fetched, parsed, instantiated, evaluated
3. Promise resolves with the module namespace object
// Observing evaluation order
// a.js
console.log('a.js evaluating');
import './b.js';
console.log('a.js done');
// b.js
console.log('b.js evaluating');
import './c.js';
console.log('b.js done');
// c.js
console.log('c.js evaluating');
console.log('c.js done');
// Output when a.js is the entry:
// c.js evaluating
// c.js done
// b.js evaluating
// b.js done
// a.js evaluating
// a.js done
Key Takeaways
- ES Modules are the standard — use
import/exportfor all new code - Named exports enable tree shaking and IDE discovery; prefer them over default exports
- Default exports work best when a module has one primary purpose
- Dynamic
import()enables code splitting and lazy loading - ESM is static (analyzed at parse time); CJS is dynamic (resolved at runtime)
- ESM has live bindings (exported values update); CJS has copies (snapshot at require time)
- Tree shaking eliminates unused code — write side-effect-free modules with named exports for best results
- Circular dependencies cause initialization issues — restructure with shared modules or dependency injection
- Organize imports: external first, internal next, relative last, types and styles at the end
- Use
"type": "module"in package.json to enable ESM in Node.js projects