ES Modules in Depth
Advanced · 18 min read · ▶ live playground · ✦ checkpoint
A single-file app feels simple until it becomes a drawer full of everything: state, DOM code, formatting helpers, and server calls all bumping into each other. ES modules (ESM) are JavaScript's standard file-to-file system: each file keeps its private names private, then shares only the pieces it deliberately exports.
Exporting and importing names
An export is a value a module makes available to other modules, and an import is a name another module brings in from that exported set. Think of a module as a small workshop room: most tools stay inside the room, and exports are the tools placed on the public shelf.
export const taxRate = 0.0825;
export function formatMoney(cents) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cents / 100);
}
export function totalWithTax(cents) {
return Math.round(cents * (1 + taxRate));
}Another file can import the exact names it needs:
import { formatMoney, totalWithTax as addTax } from "./price-tools.js";
const cartTotalCents = 4299;
const finalTotalCents = addTax(cartTotalCents);
console.log(formatMoney(finalTotalCents));If this ran in a browser page with <script type="module">, or in Node from an ESM context such as a .mjs file or a package with "type": "module", it would print $46.54. The as addTax part is an import alias, a local nickname for an imported name. The exporting module still calls the function totalWithTax; only this importing file calls it addTax.
This is a named export, an export with its own public name. Named exports usually win in app code because the exported name is stable: importers must reference totalWithTax, even if they alias it locally. Search works better. Auto-imports are clearer. A mistaken rename is easier to spot.
A default export is the one unnamed main value a module can export. It looks like export default function makeReceipt(...) { ... }, and another file can choose any import name: import buildReceipt from "./receipt-template.js";. That freedom is convenient for small one-purpose modules, but it also means different files can invent different names for the same thing. Use defaults when a file truly has one obvious main value. Use named exports for most shared helpers.
Module scope and run-once evaluation
Module scope means top-level names in one module belong to that module, not to the page or to other files. A plain const sessions = new Map(); in session-store.js doesn't become a global variable. It is a private name inside that room.
console.log("session-store evaluated");
const sessions = new Map();
export function saveSession(userName, status) {
sessions.set(userName, status);
}
export function getSessionCount() {
return sessions.size;
}Modules also have run-once evaluation, which means the engine runs a module's top-level code the first time that module is imported, then reuses the same module instance for later imports of the same file. If analytics.js and app.js both import ./session-store.js, "session-store evaluated" prints once, not twice. The private sessions map is also the same map behind every exported function.
That "same module instance" rule is why modules are good places for shared setup, caches, and registries. It is also why top-level side effects deserve restraint. If an import sends a network request, starts a timer, or mutates shared state just by being imported, every reader of that file now has to remember the hidden action.
Live bindings are views, not copies
This tiny demo builds a module from text so one file can show the behavior. Real projects use separate files like the examples above.
const moduleText = `
export let unreadCount = 0;
export function receiveMessage(sender) {
unreadCount += 1;
return \`message from \${sender}\`;
}
`;
const moduleUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(moduleText)}`;
const inbox = await import(moduleUrl);
console.log(inbox.unreadCount);
console.log(inbox.receiveMessage("Mina"));
console.log(inbox.unreadCount);→ 0, then message from Mina, then 1 — the importer reads the export after the exporting module updates it.
There is one boundary you should keep: the module that exports a let may change it, but importers should not. Importers read through the window; they don't climb through it and rearrange the room. Prefer exported functions like receiveMessage() when other files need to request a state change.
Dynamic import and top-level await
A static import is an import line the engine can see before the module runs, usually at the top of a file. Most imports should be static because they make the file's relationships obvious.
Dynamic import() is import's promise-returning form: it loads a module while code is already running. Use it when the module is optional, heavy, or only needed after a user action.
const reportData = [
{ month: "May", revenueCents: 582000 },
{ month: "June", revenueCents: 641500 },
];
if (reportData.length > 0) {
const charts = await import("./chart-view.js");
charts.drawRevenueChart(reportData);
}Top-level await is await written directly in a module, outside any async function. It fits module startup work: load a config file, choose an optional feature, prepare data before exports are ready. The tradeoff is simple: if one module waits at the top level, modules that import it wait too. Treat it like holding the front door until setup is finished.
The CommonJS past you will still meet
CommonJS is Node's older module system, built around require() to read exports and module.exports to publish them. You will still see it in older packages, Node scripts, and some config files:
const taxRate = 0.0825;
function totalWithTax(cents) {
return Math.round(cents * (1 + taxRate));
}
module.exports = {
taxRate,
totalWithTax,
};The .cjs extension tells Node this file uses CommonJS, even inside a package that otherwise uses ESM. A CommonJS consumer reads it like this: const { totalWithTax } = require("./legacy-tax.cjs");.
CommonJS is not bad code. It is history with a long tail. Modern browser code and modern course examples use ESM because it is the standard language feature, works in browsers, and gives tools a clearer picture of your file graph.
When a project grows past a few files, modules are only the first new shape. Next you need a project manifest, installable packages, scripts, and a lockfile that records exactly what landed on your machine.
Checkpoint
Answer all three to mark this lesson complete