Modules & tooling
Everything that turns files full of JS into one thing a browser can run.
Nothing in this chapter runs in the sandbox above the way earlier
.try blocks did — import/export
are only valid inside a real module, not inside an arbitrary function
body, so every example here is read, not clicked.
ESM — import and export
// math.js
export const PI = 3.14159;
export function square(n) { return n * n; }
export default function add(a, b) { return a + b; } // at most ONE default per module
// app.js
import add, { PI, square } from "./math.js"; // default + named, one import statement
import * as math from "./math.js"; // everything, under one namespace object
math.js later reassigns an exported
let, every file that imported it sees the new value —
the same "reference, not snapshot" idea from
objects and arrays,
applied to module bindings instead of object properties.
This live-binding rule, plus imports being static — resolved
before any module code runs, always at the top level, never
conditional — is exactly what lets a bundler safely
tree-shake: it can see every import/export at compile time and
delete anything nothing else actually uses, something CommonJS's
fully dynamic require() can't guarantee.
CommonJS vs ESM
| CommonJS (Node's original) | ESM | |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Loading | synchronous | can be async (dynamic import()) |
| Resolved | at runtime, can be conditional | statically, before execution |
Top-level this | module.exports | undefined |
| File markers | .cjs, or default in a plain package.json | .mjs, or "type": "module" in package.json |
Node runs both today; browsers only ever understood ESM
(<script type="module">). ESM is the forward
direction — new libraries default to it, and most tooling exists
partly to smooth over the gap for code still shipping CommonJS.
Dynamic import()
button.addEventListener("click", async () => {
const { openModal } = await import("./modal.js"); // only fetched when actually needed
openModal();
});
Unlike a static import, this one is a real function call
— it can go inside an if, a click handler, anywhere —
and it returns a promise. This is the mechanism behind
code splitting: a bundler sees a dynamic import()
and automatically cuts that module (and everything only it needs)
into its own separate file, downloaded only when that line actually
runs, instead of bloating the very first page load with code most
visitors may never trigger.
npm, package.json, semver
{
"name": "my-app",
"version": "1.4.2",
"dependencies": { "react": "^18.2.0" },
"devDependencies": { "vitest": "^4.1.10" }
}
A version is MAJOR.MINOR.PATCH — major for
breaking changes, minor for new, backward-compatible features,
patch for backward-compatible fixes. The prefix in front of a
dependency's version controls how far an install is allowed to drift:
| Range | Allows |
|---|---|
^18.2.0 | anything up to, not including, 19.0.0 — new minors and patches, never a new major |
~18.2.0 | anything up to, not including, 18.3.0 — patches only |
18.2.0 | that exact version, nothing else |
^18.2.0 is a range, not one specific version — two
installs weeks apart can legitimately resolve to different actual
versions. package-lock.json (or yarn.lock,
pnpm-lock.yaml) pins the exact resolved tree,
which is why it's committed to the repo and why "works on my
machine" so often traces back to a missing or ignored lockfile.
A bundler, briefly
A bundler (Vite, webpack, esbuild, Rollup) does three jobs at once:
follows every import to build one dependency graph,
transpiles newer syntax and JSX/TS down to something the
target browsers understand, and packs the result into as few files
as make sense (splitting where a dynamic import() says
to). The source map it emits alongside the bundle is what lets
a browser's DevTools show your original Button.tsx and
its real line numbers in a stack trace, instead of line 1 of one
giant minified file.
Linting and formatting
Two different jobs, often confused because they're configured together. ESLint reads your code for actual problems — an unused variable, a missing dependency in a React hook, a variable that shadows an outer one by accident. Prettier doesn't look for problems at all — it just rewrites every file into one consistent style (quotes, spacing, line length), so a diff shows what actually changed instead of a formatting argument. Running both: Prettier decides how the code looks, ESLint decides whether the code is right.
Opens in the editor — write it, run it, and check it against real tests.