JavaScript ES Modules: import, export, Dynamic Imports, and Module Design
For its first twenty years, JavaScript had no module system โ every script shared one global namespace, and โarchitectureโ meant naming conventions and hope. The community invented stopgaps (IIFEs, CommonJS in Node, AMD), and finally ES2015 standardized ES modules (ESM): import/export as language syntax, now native in every browser and modern Node.
Modules are where JS stops being snippets and becomes software. This guide covers the syntax and its real semantics (live bindings, single evaluation), the default-vs-named debate, dynamic imports, the CommonJS legacy youโll still meet, and the design habits that keep module graphs sane.
The Basics: Files Are Modules
Each file is a module with its own top-level scope โ nothing leaks globally, strict mode is automatic, and sharing is explicit:
export const PAISE_PER_RUPEE = 100;
export function toRupees(paise) { return paise / PAISE_PER_RUPEE;}
export function formatINR(paise) { return `โน${toRupees(paise).toLocaleString("en-IN")}`;}
const helper = () => { /* not exported = truly private to this file */ };import { formatINR, toRupees } from "./money.js";import * as money from "./money.js"; // namespace form: money.formatINR(...)
console.log(formatINR(499900)); // โน4,999.00Mechanics that matter:
- Named exports/imports must match by name โ
import { formatInr }(wrong case) is an immediate error, one of ESMโs quiet virtues: typos fail at load, not at 3 a.m. Rename locally withas:import { formatINR as inr }. - A module evaluates once, no matter how many files import it โ everyone shares the same instance. Module-level state is therefore singleton state (one cache, one config โ the closure-based module pattern, formalized).
- Imports are live bindings, not copies: if a module reassigns an exported
let, importers see the new value. Imports are also read-only views โ assigning to one is an error. (Mutating a shared objectโs contents remains possible, and remains a shared-state design smell.) - Imports hoist โ the whole module graph loads before any code runs, which is what enables static analysis, and why side-effectful import ordering is mostly an illusion worth not relying on.
In browsers, entry via <script type="module">; in Node, .mjs extension or "type": "module" in package.json. Bare specifiers (import React from "react") are resolved by bundlers/Node from node_modules; relative paths need ./.
Named vs. Default Exports โ and Why Teams Increasingly Pick Named
// default export โ one "main thing" per module, importer picks any nameexport default function CheckoutPage() { ... }import Checkout from "./CheckoutPage.js"; // name is your choice
// named โ explicit, checkable, refactorableexport function CheckoutPage() { ... }import { CheckoutPage } from "./CheckoutPage.js";Defaults look friendlier and dominate older tutorials and some framework conventions (React components, config files). But experience at scale has swung style guides toward named exports as the default choice, for concrete reasons: imports fail loudly on typos (defaults silently accept any local name, so the same module gets imported as Checkout, CheckoutPage, and checkout across a codebase); auto-import tooling and refactors work better with real names; re-exporting (export * from) composes cleanly; and one-module-many-utilities is the common case anyway.
The pragmatic rule: named by default; a default export only when the module truly is one thing and your frameworkโs convention expects it. Whatever you choose, be consistent โ mixed styles are the actual annoyance.
Re-exports build public API surfaces โ the โbarrelโ pattern:
export { createPayment, refund } from "./api.js";export { PaymentStatus } from "./types.js";// consumers: import { refund } from "features/payments" โ internals stay privateBarrels are great at package boundaries (one curated entry point) and hazardous when auto-generated everywhere (they can drag whole directories into every import and confuse tree-shaking โ see below). Curate them; donโt farm them.
Dynamic import(): Code as a Loadable Resource
Static imports load everything up front. import() โ a function-like form returning a promise โ loads a module on demand:
button.addEventListener("click", async () => { const { renderChart } = await import("./charting.js"); // fetched only now renderChart(data);});This is the mechanism behind code splitting: your bundler sees import() and cuts the target (and its dependencies) into a separate chunk, downloaded only when the line runs. The practical wins: heavy libraries (charts, editors, PDF generation) stop taxing initial page load; routes load per navigation (framework routers do this for you); rarely-used features cost nothing until used. Since itโs just a promise, everything from async/await applies โ including error handling for chunk-load failures (flaky networks make this a real path: catch and offer retry).
Static vs dynamic is also the analyzability trade: static imports form a graph tools can see without running code โ powering tree-shaking (dead-export elimination: import formatINR from a 50-export module and a good bundler ships only whatโs reachable). Tree-shaking works best with named exports from side-effect-free modules โ one more point for the named column, and a reason top-level side effects (below) cost more than style points.
CommonJS: The Legacy Youโll Meet in Node
Node predates ESM and built its own system โ CommonJS (CJS) โ which youโll recognize on sight and still encounter in packages and older codebases:
// CommonJS โ require/module.exportsconst { toRupees } = require("./money.js");module.exports = { formatINR };The differences that actually bite: CJS is synchronous and dynamic (you can require inside an if โ flexible, unanalyzable), exports are copied values not live bindings, and the two systems interop through a compatibility layer with sharp edges (default-export mismatches โ the infamous .default property โ top the list). The direction of travel is unambiguous: new code is ESM, the ecosystem has largely migrated, and modern runtimes/bundlers smooth most seams (Node can now require() many ESM modules, and dual-published packages are standard). What you need: write ESM, recognize CJS, and when an import behaves weirdly with an older package, suspect the interop layer and check the packageโs docs for its ESM entry point.
Module Design: The Habits That Keep Graphs Sane
Syntax is the easy half; the lasting skill is drawing module boundaries. Field-tested habits:
Export functions, not mutable state. A module exporting export let currentUser invites spooky-action updates from anywhere. Export accessors (getCurrentUser, setCurrentUser) or a store object with methods โ same encapsulation logic as ever, at file scale.
Keep top-level side effects near zero. A module that does things when imported (network calls, DOM writes, registrations) makes import order matter, breaks tree-shaking, and turns tests into archaeology. Export an init(); let the entry point decide when.
Watch for circular imports. A imports B imports A is legal and usually works (ESM handles it via partial evaluation), but itโs the classic source of โmy import is undefined at load timeโ mysteries โ one module runs before its dependency finished initializing. When you see the symptom, look for the cycle; the durable fix is extracting the shared piece into a third module both can import. Cycles are also an architecture smell: layers should point one way.
Structure by feature, not by kind. features/payments/{api,components,types} scales; global utils/, helpers/, components/ buckets decay into junk drawers where circular imports breed. Module paths should read like a map of the product.
Testing and Modules: Why Design Choices Come Back at Test Time
Module structure decides how testable your code is, and the connection is worth making explicit before habits set.
Exports are seams. A pure function exported from a module is trivially testable โ import, call, assert. Thatโs the purity dividend compounding at file scale, and it argues for the same layering as ever: pure logic modules at the center, side-effectful modules (network, DOM, storage) at the edges.
Module-level state is test-hostile. The single-evaluation rule means an exported cache or config initialized at import time persists across tests โ the classic mystery where tests pass alone and fail together. Fixes, in order of preference: donโt hold module state (pass dependencies in โ the constructor-injection idea, function-style); export a reset() for tests; or lean on the test runnerโs module-isolation features and accept the complexity.
Import-time side effects sabotage mocking. A module that connects to a database on import forces every test that touches its importers to deal with that connection. The init() convention from the design section isnโt just cleanliness โ itโs what makes โimport the module, mock its dependency, run the functionโ possible at all.
The one-line summary for design reviews: a module that canโt be imported harmlessly canโt be tested cheaply.
Frequently Asked Questions
Do browsers really run modules natively โ do I still need a bundler? Natively, yes โ <script type="module"> with real imports works today. Bundlers persist for different reasons: dependency resolution from npm, tree-shaking, chunking, TypeScript/JSX compilation, and minification. Dev servers (Vite) actually use native ESM for instant startup, bundling only for production.
Whatโs top-level awaitโs role here? Covered in async/await โ modules can await at top level, and importers wait. Powerful for one-time init; use restraint, since it serializes your graphโs load.
How do import maps fit in? Browser-native mapping of bare specifiers to URLs ("react" โ "https://esm.sh/react") โ the piece that lets buildless setups use package names. Niche but growing; bundler users can ignore them.
Can a module detect itโs the entry point (like Pythonโs __main__)? In Node ESM: compare import.meta.url against process.argv[1] (clunky but standard). import.meta is also home to import.meta.env-style tooling injections youโll see in Vite projects.
One export per file โ good rule? As dogma, no โ it fragments cohesive modules into import spaghetti. The unit is the concept: money.js exporting five money functions is one good module, not five files.
Versioning Reality: What package.json Ranges Actually Promise
Modules from npm arrive through semver ranges, and one mental model prevents recurring surprises: "^4.2.1" accepts any 4.x.y โฅ 4.2.1 (the default and usually right), "~4.2.1" accepts only 4.2.x, and a bare "4.2.1" pins exactly. The catch everyone learns once: ranges describe what could install, while the lockfile records what did โ so committing package-lock.json (always) is what makes builds reproducible, and โworks on my machineโ package bugs usually mean someoneโs lockfile diverged. Related habit: a dependencyโs major bump (5.0.0) is allowed to break you by contract โ read its changelog before widening the range, and treat npm audit warnings as triage input rather than a to-do list to silence. None of this is module syntax, but itโs where module practice meets production โ the import statement is only as stable as the resolution behind it.
Self-Check
export let count = 0;export const bump = () => { count += 1; };
// a.jsimport { count, bump } from "./counter.js";bump(); bump();console.log(count); // 1 โ what prints?
// b.jsimport { count } from "./counter.js";import "./a.js";console.log(count); // 2 โ and here (b.js is the entry)?
// 3 โ why might `import { debounce } from "lodash"` bloat a bundle// while `import debounce from "lodash/debounce"` doesn't (in older setups)?
// 4 โ you see: ReferenceError: Cannot access 'X' before initialization// at module load time. First suspect?Answers: (1) 2 โ live bindings: the import reflects the moduleโs current value, not a snapshot at import time. (2) 2 โ one shared module instance: bโs count is the same binding a.js bumped; and note a.js ran first (imports evaluate depth-first before the importerโs code). (3) Classic lodash is CJS โ not reliably tree-shakeable, so the named import could pull the whole library; the per-file path imports one module. Modern ESM builds (lodash-es) shake fine โ the lesson is that tree-shaking depends on the packageโs module format. (4) A circular import โ some module is reading an export before its module finished evaluating; find the cycle, extract the shared dependency.
Last stop in the series: where JavaScript meets the page โ The DOM and Events: selecting, updating, event delegation, and the browser APIs every framework abstracts but every developer still needs.