ES Modules: Import and Export
The Problem Modules Solve
Before modules, every JavaScript file shared one global scope. You loaded scripts with <script> tags, and every variable, function, and class lived in a single namespace. This caused three brutal problems:
- Name collisions — two libraries both define
init()and the last one loaded wins silently. - Order dependency —
<script>tags had to be loaded in exactly the right order, and getting it wrong produced confusing runtime errors. - No encapsulation — every internal helper was exposed globally. Change an internal function name and break someone else's code.
<!-- The old world — everything global, order matters -->
<script src="utils.js"></script> <!-- defines helper() -->
<script src="analytics.js"></script> <!-- also defines helper() — overwrites! -->
<script src="app.js"></script> <!-- uses helper() — which one? -->
ES Modules (ESM) fix all three. Each module has its own scope, you explicitly declare what you expose and what you consume, and the import order is handled automatically by the module graph.
Think of modules like apartments in a building. Each apartment (module) has its own private space — you can arrange furniture however you want and nobody else can see inside. The only way your neighbors interact with your apartment is through the front door (your exports). When you need something from another apartment, you knock on their door and ask for exactly what you need (imports). The building manager (the JavaScript engine) knows the layout of every apartment and handles all the logistics of who depends on whom.
Named Exports
Named exports are the bread and butter of ESM. You explicitly label what leaves the module, and consumers import exactly what they need by name.
Inline exports
// math.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export const PI = 3.141592653589793;
Export list
You can also declare everything normally and export at the bottom:
// math.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
const PI = 3.141592653589793;
export { add, subtract, PI };
Both styles produce identical results. The export-list style is nice when you want all your exports visible in one place.
Importing named exports
// app.js
import { add, subtract, PI } from './math.js';
console.log(add(2, 3)); // 5
console.log(PI); // 3.141592653589793
You only import what you need. If math.js exports 20 functions and you only use add, that's all you pull in. This is what makes tree shaking possible — bundlers can eliminate the unused exports from the final bundle.
Default Exports
Each module can have one default export. It represents the module's "main thing":
// Logger.js
export default class Logger {
log(message) {
console.log(`[LOG] ${message}`);
}
}
When importing a default export, you can name it whatever you want:
import Logger from './Logger.js';
import MyLogger from './Logger.js'; // same thing, different local name
You can mix default and named exports in the same module:
// api.js
export default function fetchData(url) {
return fetch(url).then(r => r.json());
}
export const BASE_URL = 'https://api.example.com';
export const TIMEOUT = 5000;
import fetchData, { BASE_URL, TIMEOUT } from './api.js';
Why Named Exports Are Generally Preferred
Default exports have a subtle problem: the import name is completely disconnected from the export name. This makes automated refactoring harder and grep-based code search unreliable.
// With default exports — every file names it differently
import Button from './Button'; // File A
import Btn from './Button'; // File B
import MyButton from './Button'; // File C
// With named exports — consistent everywhere
import { Button } from './Button'; // Always "Button"
Named exports also give you better autocomplete and better error messages when you typo the name. Most style guides (including Google's and Airbnb's) prefer named exports for these reasons.
A default export is actually just a named export with the special name default. When you write export default function foo() {}, it is roughly equivalent to export { foo as default }. This is why you can do import { default as Whatever } from './module' — but please do not write code like that.
Renaming Imports with as
Sometimes two modules export the same name. The as keyword lets you rename on import:
import { add as mathAdd } from './math.js';
import { add as dateAdd } from './date-utils.js';
mathAdd(1, 2);
dateAdd(new Date(), { days: 7 });
You can also rename on the export side:
// internal name is "calculateTotal", exported as "total"
function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
export { calculateTotal as total };
Namespace imports
If you want everything from a module under a single object:
import * as math from './math.js';
math.add(1, 2);
math.subtract(5, 3);
math.PI;
This is useful when a module exports many related things and you want to keep them namespaced. But be aware: import * pulls in everything, which can defeat tree shaking in some bundler configurations.
Re-Exporting (Barrel Files)
When you build a component library or utility package, you often want a single entry point that re-exports from multiple internal files. This is called a barrel file:
// components/index.js (barrel file)
export { Button } from './Button';
export { Input } from './Input';
export { Modal } from './Modal';
export { Dropdown } from './Dropdown';
Now consumers use one clean import:
import { Button, Modal } from './components';
You can also re-export defaults as named exports:
export { default as Button } from './Button';
export { default as Modal } from './Modal';
And re-export everything from a module:
export * from './math'; // re-exports all named exports from math
Be careful with export *. If two modules export the same name, you get a silent conflict — the ambiguous name is excluded from the re-export. This can cause hard-to-debug missing exports. Prefer explicit re-exports.
Barrel files and bundle size
Barrel files are a double-edged sword. They are clean for developer experience, but if a consumer does import { Button } from './components', some bundlers may pull in the code for Modal, Dropdown, and Input too — even though they are not used. Modern bundlers (webpack 5, Rollup, esbuild) handle this well with tree shaking, but older setups or side-effectful modules can cause bloat. The fix: mark your package as side-effect-free in package.json:
{
"sideEffects": false
}
Dynamic import()
Static import declarations load modules at parse time — before any code runs. But sometimes you want to load a module lazily, maybe a heavy chart library that is only needed when the user clicks a button. That is where dynamic import() comes in.
button.addEventListener('click', async () => {
const { renderChart } = await import('./chart-library.js');
renderChart(data);
});
Dynamic import() returns a Promise that resolves to the module's namespace object. Key differences from static imports:
- It can be used anywhere — inside
ifblocks, loops, event handlers - The module path can be a variable (within limits — bundlers need some static analysis hints)
- It enables code splitting — the imported module becomes a separate chunk
// Conditional loading based on user role
async function loadDashboard(role) {
if (role === 'admin') {
const { AdminDashboard } = await import('./AdminDashboard.js');
return AdminDashboard;
}
const { UserDashboard } = await import('./UserDashboard.js');
return UserDashboard;
}
In React, this pairs with React.lazy for component-level code splitting:
const HeavyEditor = React.lazy(() => import('./HeavyEditor'));
Module Scope
Every ES module has its own scope. Variables declared at the top level of a module are scoped to that module — they do not leak into the global scope.
// counter.js
let count = 0; // private to this module — not global
export function increment() {
count++;
}
export function getCount() {
return count;
}
// app.js
import { increment, getCount } from './counter.js';
increment();
console.log(getCount()); // 1
console.log(count); // ReferenceError — count is not exported
Strict mode by default
ES modules always run in strict mode. You do not need to write "use strict" — it is implied. This means:
- Assigning to undeclared variables throws (no accidental globals)
thisat the module top level isundefined, notwindow- Duplicate parameter names in functions are errors
- Octal literals (like
010) are syntax errors
// In a module — strict mode is automatic
x = 5; // ReferenceError: x is not defined (no accidental global)
console.log(this); // undefined (not window)
Modules are singletons
A module is evaluated once, regardless of how many files import it. Every importer gets the same module instance:
// config.js
export const settings = { theme: 'dark' };
console.log('config loaded'); // only prints ONCE
// a.js
import { settings } from './config.js'; // triggers evaluation
// b.js
import { settings } from './config.js'; // same instance, no re-evaluation
Both a.js and b.js get the exact same settings object. The console.log in config.js runs only once. This singleton behavior is what makes modules useful for shared state like configuration.
Live Bindings
Here is the thing most people miss about ES modules: exports are live bindings, not copies. When you import a value, you are not getting a snapshot — you are getting a live reference to the original variable in the source module. If the source changes the value, your import sees the change.
// counter.js
export let count = 0;
export function increment() {
count++;
}
// app.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 — the imported binding updated!
This is fundamentally different from CommonJS, where require() gives you a copy:
// CommonJS — copies, not bindings
const { count, increment } = require('./counter.js');
console.log(count); // 0
increment();
console.log(count); // 0 — still 0! You got a copy of the number.
Why live bindings matter for circular dependencies
Live bindings are not just a curiosity — they are the mechanism that makes circular dependencies work (when they work at all). If module A imports from module B, and module B imports from module A, live bindings ensure that once a value is initialized in A, module B can see it — even though at the time B first imported from A, the value had not been set yet.
Without live bindings (like in CommonJS), you get undefined for the uninitialized values and they never update. With ESM, the binding eventually resolves to the correct value once the module finishes evaluating.
That said, circular dependencies are still fragile and should be avoided when possible. The fact that they sometimes work does not mean they are a good idea.
Imports Are Read-Only
While imports are live (they update when the source changes), you cannot reassign them from the importing side:
import { count } from './counter.js';
count = 10; // TypeError: Assignment to constant variable
This is true even if the export is a let. Imports are always read-only bindings in the consuming module. Only the module that owns the export can change it.
This is a feature, not a limitation. It enforces clear data ownership — the module that defines a value is the only one that can change it. If you need to modify state, you export a function that does the mutation internally.
Top-Level Await
ES modules support await at the top level — no need to wrap it in an async function:
// data.js
const response = await fetch('/api/config');
export const config = await response.json();
The module that uses top-level await pauses its own evaluation (and the evaluation of any module that imports from it) until the promise resolves. This is powerful for initialization patterns — loading config, establishing database connections, or fetching feature flags before the app starts.
// db.js
const connection = await connectToDatabase();
export { connection };
// app.js
import { connection } from './db.js';
// connection is guaranteed to be ready here
Top-level await only works in ES modules, not in CommonJS or classic scripts. It also blocks the module graph — if your await takes 5 seconds, every module that depends on yours waits 5 seconds too. Use it for essential initialization, not for things that could load lazily.
Practical Patterns
Organizing a feature folder
A common pattern in production codebases is organizing related code into feature folders with a barrel file:
features/
auth/
index.js ← barrel file
LoginForm.js
SignupForm.js
useAuth.js
auth-utils.js
// features/auth/index.js
export { LoginForm } from './LoginForm';
export { SignupForm } from './SignupForm';
export { useAuth } from './useAuth';
// auth-utils.js is intentionally NOT exported — it's internal
The barrel file controls the module's public API. Internal helpers stay private.
Constants and configuration
// constants.js
export const ROUTES = {
home: '/',
dashboard: '/dashboard',
settings: '/settings',
} as const;
export const API = {
baseUrl: process.env.API_URL,
timeout: 10_000,
} as const;
Circular dependency awareness
Circular dependencies happen when module A imports from B and B imports from A. ES modules can handle some circular cases thanks to live bindings, but they are fragile:
// a.js
import { b } from './b.js';
export const a = 'A: ' + b; // b might not be initialized yet!
// b.js
import { a } from './a.js';
export const b = 'B: ' + a; // a might not be initialized yet!
One of these will see an uninitialized binding. Which one depends on which module is evaluated first (the entry point of the module graph).
The fix is to break the cycle — extract shared code into a third module that both A and B import from, or restructure so the dependency is one-directional.
| What developers do | What they should do |
|---|---|
| Treating imported values as copies you can reassign Assigning to an import throws TypeError. Export a setter function if the consuming module needs to trigger a change. | Imports are live read-only bindings. Only the exporting module can change the value. |
| Using export * from barrel files without checking for name collisions If two sub-modules export the same name, export * silently drops the ambiguous name — no error, just a missing export. | Use explicit named re-exports to control exactly what leaves the barrel file. |
| Assuming dynamic import() is synchronous like require() Unlike CommonJS require(), dynamic import() is async by design to support lazy loading and code splitting. | Dynamic import() always returns a Promise. Use await or .then() to get the module. |
| Creating circular dependencies between modules Circular imports can cause one side to see uninitialized bindings. They technically work in some cases thanks to live bindings, but they are fragile and confusing. | Extract shared code into a third module that both sides import from. |
| Putting side effects at the top level of modules that are re-exported via barrel files Importing from a barrel file may trigger evaluation of all re-exported modules, running side effects you did not intend. Mark your package sideEffects: false for proper tree shaking. | Keep modules side-effect-free. Move side effects into explicit init functions. |
- 1ES modules have their own scope — top-level variables are module-private, not global.
- 2Named exports are preferred over default exports for consistent naming, better tooling, and easier refactoring.
- 3Imports are live read-only bindings — they reflect changes from the source module but cannot be reassigned by the consumer.
- 4Modules are singletons — evaluated once, shared across all importers.
- 5Dynamic import() returns a Promise and enables lazy loading and code splitting.
- 6Modules always run in strict mode — no 'use strict' needed.
- 7Barrel files (re-exports) are great for public APIs but need sideEffects: false for proper tree shaking.