← All concepts
Modern JavaScript

ES Modules

Each file is its own module with its own scope, and export/import wire live values between files, not copies.

Definition

An ES module is just a JavaScript file that uses export to expose bindings and import to read bindings exposed by other files. Each module has its own top level scope, so a variable declared in one file is not visible in another unless it is explicitly exported. A module can export multiple named values with export, and at most one default export with export default, which is meant to be the file's main thing and is imported without curly braces. An import is a live, read only binding to the exporting module's variable, not a copied snapshot taken at import time. If the exporting module later reassigns an exported let, every module that imported it sees the updated value the next time it reads that binding, because they are all looking at the same underlying variable. Modules are also singletons: no matter how many files import a given module, its code runs once, the first time it is loaded, and every importer shares that same single instance. On top of that, module code always runs in strict mode automatically, even without a "use strict" directive.

Examples

// math.js
export const PI = 3.14159;
export function double(n) {
  return n * 2;
}

// app.js
import { PI, double } from "./math.js";
double(PI); // 6.28318

math.js exposes two named exports. app.js imports them by name inside curly braces, matching the names they were exported with.

// logger.js
export default function log(msg) {
  console.log(msg);
}

// app.js
import log from "./logger.js";
log("ready"); // ready

logger.js has one default export, so app.js imports it without curly braces and can name it anything on the way in.

// counter.js
export let total = 0;
export function increment() {
  total++;
}

// app.js
import { total, increment } from "./counter.js";
total; // 0
increment();
total; // 1

increment() mutates counter.js's total. Because the import is a live binding and not a copy, app.js reads the updated value of total after the call, without importing anything new.

Common mistakes

  • Assuming an imported value is a snapshot copied at import time; it is a live binding, so it reflects later changes the exporting module makes.
  • Mixing up default and named exports, importing a default with curly braces or a named export without them fails.
  • Forgetting that module code always runs in strict mode, so patterns that rely on sloppy mode, like an undeclared global, throw instead of silently working.
  • Expecting a module's top-level code to rerun on every import; it only runs once, and every importer shares that same single instance.

Key takeaways

  • export exposes bindings from a file, import reads them, no curly braces for a default export, curly braces for named ones.
  • An import is a live, read only binding to the exporter's variable, not a copy taken at import time.
  • A module's code runs once no matter how many files import it, and everyone shares that same instance.
  • Module code runs in strict mode automatically, even without a "use strict" directive.
Check your understanding

// counter.js export let count = 0; export function inc() { count++; } // app.js import { count, inc } from "./counter.js"; inc(); console.log(count); What does this log?

Where you see this

  • Splitting an app into focused files (a math helper, a logger, a set of constants) instead of one giant script.
  • Sharing a single configuration object or cache across every file that imports it, since modules run once and are shared.
  • Choosing a default export for a file's one main component or function, and named exports for its smaller supporting pieces.
  • Bundlers and build tools like webpack, Rollup, and Vite relying on static export/import syntax to tree shake unused code.

Practice this

Further reading

Previous
Error Handling: try, catch, finally
Next
Immutable Update Patterns