JavaScript Modules

Modules split code into reusable, encapsulated files. Learn ES module import/export syntax, named vs default exports, dynamic import(), and the difference from CommonJS.

Intermediate 10 min read 10 examples

Why Modules?

Without modules, all JavaScript code shares a single global scope - variables from one script collide with another. ES Modules solve this by giving each file its own scope and making dependencies explicit via import/export.

HTML index.html
<!-- Regular script: shared global scope -->
<script src="utils.js"></script>
<script src="app.js"></script>

<!-- ES Module: each file has its own scope -->
<script type="module" src="app.js"></script>
JavaScript Module benefits
// Module scope: top-level variables are NOT global
const secret = "only in this file"; // not accessible from other scripts

// Modules are strict mode by default
// this === undefined at top level (not window)

// Modules are evaluated once and cached
// Importing the same module twice returns the same instance

Named Exports

Named exports can export multiple values from a file. Consumers import them by their exact name (with optional renaming).

JavaScript math.js
// Named exports: export individual declarations
export const PI = 3.14159265358979;

export function add(a, b) {
    return a + b;
}

export function subtract(a, b) {
    return a - b;
}

export class Vector {
    constructor(x, y) { this.x = x; this.y = y; }
    length() { return Math.sqrt(this.x ** 2 + this.y ** 2); }
}

// Alternatively: export a list at the end of the file
const multiply = (a, b) => a * b;
const divide   = (a, b) => a / b;
export { multiply, divide };

// Export with rename
const internal_helper = () => {};
export { internal_helper as helper };  // exported name is 'helper'

Default Exports

Each module can have one default export. The importer can give it any name.

JavaScript user.js
// Default export: one per module
export default class User {
    constructor(name, email) {
        this.name  = name;
        this.email = email;
    }
    greet() { return `Hi, I am ${this.name}`; }
}

// Can also export a function, object, or value as default
// export default function greet() { ... }
// export default { name: "config" };
// export default 42;

// Can combine default and named exports
export const USER_ROLES = ["admin", "user", "guest"]; // named
// (default export above is the User class)

Import Syntax

JavaScript app.js
// Named imports - must use exact exported name
import { add, subtract, PI } from "./math.js";
console.log(add(2, 3));   // 5
console.log(PI);          // 3.14159...

// Rename on import
import { add as sum, subtract as diff } from "./math.js";
console.log(sum(2, 3));   // 5
console.log(diff(5, 2));  // 3

// Import all named exports as a namespace object
import * as MathUtils from "./math.js";
console.log(MathUtils.add(2, 3));  // 5
console.log(MathUtils.PI);         // 3.14159...

// Default import - any name works
import User from "./user.js";
import MyUser from "./user.js"; // same thing, different local name

// Default + named together
import User, { USER_ROLES } from "./user.js";

// Import with .js extension is required in native ESM
// Bundlers (Vite, Webpack) often allow omitting it
import { helper } from "./utils.js"; // browser/Node.js
import { helper } from "./utils";    // bundler only
Prefer Named Exports

Named exports are generally preferred over default exports because: (1) editors can auto-complete the exact name, (2) refactoring tools can rename them everywhere automatically, (3) they make the module's public API explicit. Default exports are common for React components (one component per file), but named exports are better for utility modules.

Re-exports and Barrel Files

Re-exports let you create a single entry-point file (barrel) that collects and re-exports from multiple modules. This simplifies import paths for consumers.

JavaScript index.js (barrel file)
// src/utils/index.js - barrel file
export { add, subtract, PI } from "./math.js";   // re-export named
export { default as User } from "./user.js";      // re-export default as named
export * from "./string-utils.js";                // re-export everything
export * as dom from "./dom-utils.js";            // re-export as namespace

// Consumer can now import from one place
// Instead of:
import { add } from "./utils/math.js";
import User from "./utils/user.js";

// They can do:
import { add, User } from "./utils/index.js";
// or even:
import { add, User } from "./utils"; // if bundler resolves index.js

Dynamic import()

Dynamic import() loads a module asynchronously and returns a Promise. Use it for code splitting and lazy loading.

JavaScript
// Dynamic import - returns a Promise
async function loadChartLibrary() {
    const { Chart } = await import("./chart.js");
    return new Chart(document.getElementById("canvas"));
}

// Conditional loading
async function loadLocale(lang) {
    const locale = await import(`./locales/${lang}.js`);
    return locale.default; // access default export
}

// Lazy load on user action
document.getElementById("btn").addEventListener("click", async () => {
    const { openModal } = await import("./modal.js");
    openModal();
});

// Load multiple in parallel
async function loadDashboard() {
    const [charts, tables, maps] = await Promise.all([
        import("./charts.js"),
        import("./tables.js"),
        import("./maps.js"),
    ]);
    // use charts.default, tables.default, etc.
}

// Static imports are analyzed at build time for tree-shaking
// Dynamic imports are code-split - separate chunks in the bundle

Module Scope

JavaScript
// Module-level code runs once when first imported
console.log("module.js loaded"); // printed once, even if imported by 10 files

// Module state is shared across all importers
let count = 0;
export function increment() { count++; }
export function getCount()  { return count; }

// In file A:
import { increment, getCount } from "./counter.js";
increment(); // count = 1

// In file B (same page):
import { getCount } from "./counter.js";
console.log(getCount()); // 1 (same module instance!)

// Module top-level is NOT the global object
const x = 1; // not window.x - stays local to module

// But you can explicitly set global if needed (rare)
window.myLib = { version: "1.0" }; // not recommended

// this at module top level is undefined (not window)
console.log(this); // undefined

CommonJS vs ESM

FeatureCommonJS (require)ES Modules (import)
Syntaxrequire() / module.exportsimport / export
LoadingSynchronousAsynchronous
EvaluationRuntimeParse time (static)
ConditionalYes (if (x) require(...))No (use dynamic import())
Tree-shakingNoYes
Default inNode.js (legacy)Browser, modern Node.js
File extension.js, .cjs.js (with "type":"module"), .mjs
JavaScript CommonJS example
// CommonJS (Node.js legacy) - still widely used in npm packages
const fs = require("fs");
const { add, subtract } = require("./math");
module.exports = { myFunc };
module.exports.default = MyClass;

// ESM in Node.js - requires "type": "module" in package.json
// or .mjs file extension
import fs from "node:fs/promises";
import { add } from "./math.js";  // .js extension required

Frequently Asked Questions

Named exports can be multiple per file and must be imported using the exact name (or renamed with as): import { add, subtract } from "./math.js". Default exports are one per file and can be imported with any name: import MathUtils from "./math.js". Best practice: prefer named exports - they are easier to auto-complete and refactor in editors, and make it obvious what a module provides.

Yes - add type="module" to your script tag. ES modules work natively in all modern browsers. However, each import triggers a separate HTTP request, so bundlers (Vite, Webpack, Rollup) are still used in production to combine modules into fewer files. For development, native modules work fine and tools like Vite use native modules with HMR (Hot Module Replacement).

Dynamic import() returns a Promise that resolves to the module. Use it for code splitting - loading modules only when needed. Examples: loading a heavy charting library only when the user navigates to a dashboard, importing locale files based on the user's language setting, or loading admin-only code only after verifying the user is an admin. This reduces the initial bundle size and speeds up page load.

CommonJS (require()) is the Node.js module system - synchronous, evaluated at runtime, can be conditional. ES Modules (import) are the standard for browsers and modern Node.js - static (analyzed at parse time), always at the top of the file, cannot be conditional. ESM enables tree-shaking (dead code elimination) because bundlers can statically determine what is imported. Modern Node.js supports both; in new projects, prefer ESM.