Technology  /  JavaScript

๐ŸŸจ JavaScript 15 guides ยท updated 2026

Closures, the event loop, promises, and modern ES syntax โ€” the language of the web explained the way it actually behaves at runtime.

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:

money.js
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 */ };
checkout.js
import { formatINR, toRupees } from "./money.js";
import * as money from "./money.js"; // namespace form: money.formatINR(...)
console.log(formatINR(499900)); // โ‚น4,999.00

Mechanics that matter:

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 name
export default function CheckoutPage() { ... }
import Checkout from "./CheckoutPage.js"; // name is your choice
// named โ€” explicit, checkable, refactorable
export 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:

features/payments/index.js
export { createPayment, refund } from "./api.js";
export { PaymentStatus } from "./types.js";
// consumers: import { refund } from "features/payments" โ€” internals stay private

Barrels 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.exports
const { 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

counter.js
export let count = 0;
export const bump = () => { count += 1; };
// a.js
import { count, bump } from "./counter.js";
bump(); bump();
console.log(count); // 1 โ€” what prints?
// b.js
import { 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.