Why Modules?
Modules help organize code into separate files, avoid naming conflicts, and make code reusable and maintainable.
// Without modules - global scope pollution
// file1.js
var userName = "John";
function greet() { console.log("Hello"); }
// file2.js
var userName = "Jane"; // Conflict! Overwrites file1.js
function greet() { } // Conflict!
// With modules - each file has its own scope
// user.js
export const userName = "John";
export function greet() { console.log("Hello"); }
// app.js
import { userName, greet } from "./user.js";
// No conflicts, explicit dependencies
- Encapsulation - private by default
- Explicit dependencies - clear imports
- Reusability - import anywhere
- Maintainability - smaller, focused files
- Tree shaking - remove unused code
Exporting
Export variables, functions, and classes to make them available to other modules.
// ===== Named Exports =====
// math.js
// Export inline
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export class Calculator {
multiply(a, b) {
return a * b;
}
}
// Or export at the end
const E = 2.71828;
function divide(a, b) {
return a / b;
}
export { E, divide };
// Export with alias
export { divide as div };
// ===== Default Export =====
// Each module can have ONE default export
// logger.js
export default class Logger {
log(message) {
console.log(`[LOG] ${message}`);
}
error(message) {
console.error(`[ERROR] ${message}`);
}
}
// Or for functions
// greet.js
export default function greet(name) {
return `Hello, ${name}!`;
}
// Or for values
// config.js
export default {
apiUrl: "https://api.example.com",
timeout: 5000,
debug: true
};
// ===== Mixed Exports =====
// utils.js
export const VERSION = "1.0.0";
export function helper() { }
export default class Utils {
static format(value) { }
}
Importing
Import modules to use their exports in your code.
// ===== Named Imports =====
import { add, subtract, PI } from "./math.js";
console.log(add(2, 3)); // 5
console.log(PI); // 3.14159
// Import with alias
import { add as addition, subtract as sub } from "./math.js";
console.log(addition(2, 3)); // 5
// Import all as namespace
import * as math from "./math.js";
console.log(math.add(2, 3)); // 5
console.log(math.PI); // 3.14159
// ===== Default Imports =====
import Logger from "./logger.js";
// Can use any name for default imports
import MyLogger from "./logger.js"; // Same thing
const logger = new Logger();
logger.log("Hello");
// ===== Mixed Imports =====
// Import default and named together
import Utils, { VERSION, helper } from "./utils.js";
console.log(VERSION);
Utils.format("test");
// ===== Side-Effect Imports =====
// Import module for its side effects only
import "./polyfills.js";
import "./styles.css"; // With bundlers like webpack
// ===== Import Paths =====
// Relative paths
import { func } from "./utils.js"; // Same directory
import { func } from "../utils.js"; // Parent directory
import { func } from "./lib/utils.js"; // Subdirectory
// Absolute paths (with bundler/config)
import { func } from "@/utils"; // Alias
import { func } from "lodash"; // node_modules
In browsers and Node.js ES modules, you must include the file extension (e.g., ./utils.js). Bundlers like webpack may allow omitting it.
Dynamic Imports
Load modules on demand for code splitting and lazy loading.
// Dynamic import returns a Promise
const module = await import("./math.js");
console.log(module.add(2, 3));
// With .then()
import("./math.js").then(module => {
console.log(module.PI);
});
// Conditional loading
if (needsFeature) {
const { feature } = await import("./feature.js");
feature();
}
// Load based on user action
button.addEventListener("click", async () => {
const { openModal } = await import("./modal.js");
openModal();
});
// Load based on route
async function loadPage(route) {
switch (route) {
case "/home":
return import("./pages/home.js");
case "/about":
return import("./pages/about.js");
case "/contact":
return import("./pages/contact.js");
default:
return import("./pages/404.js");
}
}
// Dynamic path (variable)
const lang = "en";
const translations = await import(`./locales/${lang}.js`);
// Import default export
const { default: Component } = await import("./Component.js");
// Error handling
try {
const module = await import("./might-not-exist.js");
} catch (error) {
console.error("Failed to load module:", error);
// Fallback behavior
}
// Preload modules
function preloadModule(path) {
const link = document.createElement("link");
link.rel = "modulepreload";
link.href = path;
document.head.appendChild(link);
}
preloadModule("./heavy-feature.js");
Re-exporting
Create barrel files to simplify imports from multiple modules.
// utils/index.js - Barrel file
// Re-export everything from a module
export * from "./math.js";
export * from "./string.js";
export * from "./array.js";
// Re-export specific items
export { add, subtract } from "./math.js";
export { capitalize, trim } from "./string.js";
// Re-export with rename
export { add as sum } from "./math.js";
// Re-export default as named
export { default as Logger } from "./Logger.js";
export { default as Formatter } from "./Formatter.js";
// Now import from one place
// app.js
import {
add,
subtract,
capitalize,
Logger
} from "./utils/index.js";
// Or simply
import { add, capitalize } from "./utils";
// ===== Organizing a project =====
// components/index.js
export { Button } from "./Button.js";
export { Modal } from "./Modal.js";
export { Card } from "./Card.js";
export { Input } from "./Input.js";
// Usage
import { Button, Modal, Card } from "./components";
// ===== API module pattern =====
// api/index.js
export { fetchUsers, createUser } from "./users.js";
export { fetchPosts, createPost } from "./posts.js";
export { login, logout } from "./auth.js";
// All API functions in one import
import * as api from "./api";
api.fetchUsers();
api.login();
Modules in Browser
Use ES modules directly in modern browsers.
<!-- Use type="module" to enable ES modules -->
<script type="module" src="./app.js"></script>
<!-- Inline module -->
<script type="module">
import { greet } from "./utils.js";
greet("World");
</script>
<!-- Fallback for older browsers -->
<script type="module" src="./app.js"></script>
<script nomodule src="./app-legacy.js"></script>
// Module behavior differences
// 1. Modules are deferred by default
// (equivalent to defer attribute)
// 2. Modules run in strict mode automatically
// "use strict" is implied
// 3. Top-level 'this' is undefined
console.log(this); // undefined in module, window in script
// 4. Top-level variables are scoped to module
const secret = "hidden"; // Not on window object
// 5. import.meta - module metadata
console.log(import.meta.url); // Full URL of current module
// 6. Modules are loaded once and cached
import "./init.js"; // Runs once
import "./init.js"; // Uses cached version
// 7. CORS required for cross-origin modules
// Modules from other origins need proper CORS headers
CommonJS (Node.js)
Node.js traditionally uses CommonJS modules. Understanding both systems is important.
// ===== CommonJS (Node.js traditional) =====
// math.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
module.exports = { add, subtract };
// or
exports.add = add;
exports.subtract = subtract;
// app.js
const math = require("./math");
console.log(math.add(2, 3));
const { add, subtract } = require("./math");
// ===== ES Modules in Node.js =====
// package.json
{
"type": "module" // Enable ES modules for .js files
}
// Or use .mjs extension for ES modules
// math.mjs
export const add = (a, b) => a + b;
// app.mjs
import { add } from "./math.mjs";
// ===== Key Differences =====
// CommonJS:
// - require() is synchronous
// - Can use conditionally
// - Variables are copied
// - require is a function call
// ES Modules:
// - import is asynchronous
// - Static (top-level only)*
// - Variables are live bindings
// - import is a declaration
// *Dynamic import() works anywhere
// Interop: Import CommonJS in ES Module
import pkg from "./commonjs-module.cjs";
const { namedExport } = pkg;
// Interop: Require ES Module in CommonJS
// (requires async context)
const module = await import("./es-module.mjs");
Dynamic Imports Deep Dive
Dynamic imports enable code splitting and lazy loading, improving initial load times by loading code only when needed.
Route-Based Code Splitting
// Router with dynamic imports
class Router {
constructor(routes) {
this.routes = routes;
this.cache = new Map();
window.addEventListener('popstate', () => this.navigate());
this.navigate();
}
async navigate(path = window.location.pathname) {
const route = this.routes[path] || this.routes['/404'];
try {
let module;
// Cache loaded modules
if (this.cache.has(route)) {
module = this.cache.get(route);
} else {
// Show loading state
this.showLoader();
// Dynamic import with webpackChunkName (for webpack)
module = await import(/* webpackChunkName: "[request]" */ route);
this.cache.set(route, module);
}
// Render the page component
const content = await module.default();
document.getElementById('app').innerHTML = content;
} catch (error) {
console.error('Route loading failed:', error);
this.showError();
} finally {
this.hideLoader();
}
}
showLoader() { /* ... */ }
hideLoader() { /* ... */ }
showError() { /* ... */ }
}
// Usage
const router = new Router({
'/': './pages/Home.js',
'/about': './pages/About.js',
'/products': './pages/Products.js',
'/404': './pages/NotFound.js'
});
Feature-Based Loading
// Load heavy features only when needed
class App {
async showChart(data) {
// Load chart library only when user needs charts
const { Chart } = await import('chart.js');
const chart = new Chart(this.canvas, {
type: 'bar',
data: data
});
return chart;
}
async enableRichTextEditor() {
// Load editor on demand
const Quill = (await import('quill')).default;
this.editor = new Quill('#editor', {
theme: 'snow'
});
}
async loadPolyfillsIfNeeded() {
// Conditional polyfill loading
if (!('IntersectionObserver' in window)) {
await import('intersection-observer');
}
if (!('ResizeObserver' in window)) {
const { ResizeObserver } = await import('@juggle/resize-observer');
window.ResizeObserver = ResizeObserver;
}
}
}
// Prefetch for anticipated navigation
function prefetchModule(path) {
const link = document.createElement('link');
link.rel = 'modulepreload';
link.href = path;
document.head.appendChild(link);
}
// Prefetch likely next pages
document.querySelectorAll('a').forEach(link => {
link.addEventListener('mouseenter', () => {
prefetchModule(getModulePathForLink(link));
}, { once: true });
});
Tree Shaking
Tree shaking eliminates dead code (unused exports) from your bundles. It relies on ES module static analysis to determine what code is actually used.
Writing Tree-Shakeable Code
// ✅ Good: Named exports are tree-shakeable
// utils.js
export function formatDate(date) { /* ... */ }
export function formatCurrency(amount) { /* ... */ }
export function formatPhone(phone) { /* ... */ }
// Only formatDate is included in bundle
import { formatDate } from './utils.js';
// ❌ Bad: Default export of object
// utils.js
export default {
formatDate: () => { /* ... */ },
formatCurrency: () => { /* ... */ },
formatPhone: () => { /* ... */ }
};
// Entire object is included
import utils from './utils.js';
utils.formatDate();
// ✅ Good: Named + default for flexibility
// Button.js
export function Button(props) { /* ... */ }
export function IconButton(props) { /* ... */ }
export default Button;
// ✅ Good: Re-export pattern for libraries
// index.js (package entry point)
export { Button, IconButton } from './Button.js';
export { Modal } from './Modal.js';
export { Tooltip } from './Tooltip.js';
// Consumers can import only what they need
import { Button } from 'my-ui-library';
// ❌ Bad: Side effects prevent tree shaking
// If module has side effects, bundler can't remove it
let initialized = false;
export function init() {
initialized = true; // Side effect!
}
export function getData() {
if (!initialized) throw new Error('Not initialized');
return data;
}
// ✅ Good: Mark side-effect-free in package.json
{
"name": "my-library",
"sideEffects": false,
// Or list specific files with side effects:
"sideEffects": ["./src/polyfills.js", "*.css"]
}
Circular Dependencies
Circular dependencies occur when two or more modules import each other. While sometimes unavoidable, they can cause subtle bugs and should generally be avoided.
Understanding the Problem
// ❌ Circular dependency problem
// a.js
import { b } from './b.js';
export const a = 'Module A';
console.log('A sees b as:', b);
// b.js
import { a } from './a.js';
export const b = 'Module B';
console.log('B sees a as:', a); // undefined! (a.js hasn't finished executing)
// When you run: node a.js
// Output:
// "B sees a as: undefined" <- Problem!
// "A sees b as: Module B"
// ✅ Solution 1: Lazy access (access at runtime, not module load)
// a.js
import * as bModule from './b.js';
export const a = 'Module A';
export function getB() {
return bModule.b; // Access when called, not when module loads
}
// b.js
import * as aModule from './a.js';
export const b = 'Module B';
export function getA() {
return aModule.a; // Now works!
}
Breaking Circular Dependencies
// ✅ Solution 2: Extract shared code to third module
// Before (circular):
// user.js imports post.js, post.js imports user.js
// After:
// shared.js - common types/interfaces
export class Entity { /* ... */ }
// user.js
import { Entity } from './shared.js';
export class User extends Entity { /* ... */ }
// post.js
import { Entity } from './shared.js';
export class Post extends Entity { /* ... */ }
// ✅ Solution 3: Dependency injection
// Before (circular)
// logger.js
import { config } from './config.js';
export class Logger { /* uses config */ }
// config.js
import { Logger } from './logger.js';
export const config = { /* uses Logger */ };
// After (no circular)
// logger.js - no imports from config
export class Logger {
constructor(config) {
this.config = config; // Injected!
}
}
// config.js
import { Logger } from './logger.js';
export const config = { level: 'info' };
export const logger = new Logger(config); // Inject config
// ✅ Solution 4: Factory pattern
// registry.js - central registry
const registry = new Map();
export function register(name, factory) {
registry.set(name, factory);
}
export function get(name) {
const factory = registry.get(name);
return factory ? factory() : null;
}
// moduleA.js
import { register, get } from './registry.js';
register('A', () => ({
doSomething() {
const B = get('B'); // Get B lazily
return B.doSomethingElse();
}
}));
// moduleB.js
import { register, get } from './registry.js';
register('B', () => ({
doSomethingElse() {
return 'Hello from B';
}
}));
- ESLint: Use
eslint-plugin-importwithimport/no-cyclerule - Webpack: Use
circular-dependency-plugin - Madge: Visualize dependencies with
npx madge --circular src/ - Signs: Undefined imports at runtime, unexpected initialization order
Summary
Named Export
export { name } - multiple per file
Default Export
export default - one per file
Named Import
import { name } - specific items
Dynamic Import
import() - load on demand
Barrel Files
Re-export for cleaner imports
Browser Usage
<script type="module">