JavaScript Modules: Import and Export
JavaScript modules are the backbone of modern web development. They let you split your code into reusable, self-contained pieces — each with its own scope — then stitch them together with a clean import/export syntax. Before ES6 introduced the native module system in 2015, developers juggled script tags, global variables, and third-party solutions like RequireJS just to share code between files. Today, ES modules (ESM) are supported natively in all modern browsers and Node.js, making modular JavaScript the standard rather than the exception.
Modules enforce encapsulation: every variable and function declared in a module is private by default. Only what you explicitly export is accessible to the outside world. This single property eliminates an entire class of bugs caused by accidental global state mutation.
Life Before ES Modules
To appreciate ES modules, it helps to understand the pain points they replaced. In the early days of JavaScript, the only way to share code across files was to load multiple <script> tags in the right order and rely on global variables. This approach had serious problems:
- Global namespace pollution: Every library dumped its names into
window, risking collisions (jQuery's$, Prototype's$, etc.) - Implicit dependencies: If you loaded
utils.jsbeforeapp.js, things worked. Reverse the order and everything broke — silently. - No encapsulation: Any code on the page could read or overwrite any variable. There was no concept of private scope at the file level.
- Synchronous loading only: Every script blocked page rendering while it downloaded and parsed.
The community invented workarounds. The Immediately Invoked Function Expression (IIFE) pattern created a private scope, and later CommonJS (used in Node.js) and AMD (RequireJS) brought module systems to the browser — but they required build tools or runtime loaders.
// IIFE pattern — manual encapsulation
const MathUtils = (function() {
const PI = 3.14159; // private
function circleArea(r) {
return PI * r * r;
}
return { circleArea }; // public API
})();
// CommonJS (Node.js) — require/module.exports
const { circleArea } = require('./math-utils');
module.exports = { circleArea, squareArea };
// AMD (RequireJS) — define/require
define(['jquery'], function($) {
return { render: function() { /* ... */ } };
});
CommonJS (require/module.exports) is still the default in Node.js for .js files. ES modules use import/export and require either .mjs extension, "type": "module" in package.json, or a bundler like Vite or webpack. Mixing them has nuances — be aware of your runtime environment.
ES6 Module Fundamentals
ES modules bring a standardized, static module system to JavaScript. "Static" means the dependency graph is resolved at parse time, before any code runs — enabling tree-shaking, circular dependency detection, and better tooling support.
ES Module dependency graph — resolved statically at parse time
Using Modules in the Browser
To use ES modules in the browser, add type="module" to your script tag. This changes several behaviors: the script is deferred by default, has its own scope, and can use import/export.
<!-- Classic script: global scope, no import/export -->
<script src="app.js"></script>
<!-- Module script: private scope, supports import/export -->
<script type="module" src="app.js"></script>
<!-- Inline module -->
<script type="module">
import { greet } from './utils.js';
greet('World');
</script>
Exporting from a Module
There are two kinds of exports in ES modules: named exports and default exports. Understanding the difference is fundamental — they serve different purposes and have different import syntax.
Named Exports
Named exports let you export multiple values from a single module. Each export has an explicit name that the importing module must know.
// Export declarations inline
export const PI = 3.14159265;
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export class Calculator {
multiply(a, b) { return a * b; }
divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
}
// OR: export at the end (grouped export list)
const GRAVITY = 9.81;
function square(n) { return n * n; }
function cube(n) { return n * n * n; }
export { GRAVITY, square, cube };
// Export with rename
export { square as pow2, cube as pow3 };
Default Exports
Each module can have at most one default export. Default exports are typically used for the primary thing a module provides — a class, a component, or a main function.
// Default export: a class
export default class UserService {
constructor(apiUrl) {
this.apiUrl = apiUrl;
}
async getUser(id) {
const response = await fetch(`${this.apiUrl}/users/${id}`);
if (!response.ok) throw new Error('User not found');
return response.json();
}
async createUser(data) {
const response = await fetch(`${this.apiUrl}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return response.json();
}
}
// Default export: a function
export default function formatDate(date) {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(new Date(date));
}
// Default export: an anonymous expression
export default {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
};
Importing from a Module
The import syntax mirrors the export syntax — named imports use curly braces, while default imports use any name you choose.
Import specific named exports using destructuring-like syntax:
import { add, subtract, PI } from './math.js';
console.log(add(2, 3)); // 5
console.log(PI); // 3.14159265
// Rename on import to avoid conflicts or for clarity
import { square as pow2, cube as pow3 } from './math.js';
console.log(pow2(4)); // 16
console.log(pow3(2)); // 8
Import the default export with any name you choose — no curly braces:
// The name you give a default import is entirely up to you
import UserService from './UserService.js';
import formatDate from './formatDate.js';
const service = new UserService('https://api.example.com');
const user = await service.getUser(1);
console.log(formatDate(user.createdAt));
// "January 15, 2026"
// You can name it whatever makes sense in context
import getUser from './UserService.js'; // also valid
Import everything from a module as a single namespace object:
import * as MathUtils from './math.js';
console.log(MathUtils.add(5, 3)); // 8
console.log(MathUtils.PI); // 3.14159265
console.log(MathUtils.GRAVITY); // 9.81
// Useful for utility modules with many exports
import * as validators from './validators.js';
if (validators.isEmail(input)) {
// ...
}
// Note: the namespace object is live — it reflects
// current export values if they change (rare in practice)
Import both the default and named exports in one statement:
// A module can have both a default AND named exports
// utils.js
export default function log(msg) { console.log(msg); }
export const VERSION = '2.0.0';
export function warn(msg) { console.warn(msg); }
// Import default first, then named in braces
import log, { VERSION, warn } from './utils.js';
log('App started'); // default
warn('Low memory'); // named
console.log(VERSION); // named: '2.0.0'
// Rename the default import too
import myLogger, { VERSION as v } from './utils.js';
Named vs Default: Which to Use?
This is one of the most debated questions in JavaScript module design. Both are valid — the choice depends on what the module represents.
| Aspect | Named Export | Default Export |
|---|---|---|
| Quantity per module | Many | One |
| Import syntax | import { name } |
import anyName |
| Import name | Must match export name (or alias) | Any name you choose |
| Refactoring safety | High — IDEs can find all usages | Lower — names can drift between files |
| Best for | Utility modules, constants, multiple related items | A single class, component, or main function |
| Tree-shaking | Excellent — only used exports are bundled | Good — whole default is included |
| Common in | Utility libraries (lodash-es, date-fns) | React components, Vue SFCs, services |
Many style guides (including the Airbnb JavaScript Style Guide) recommend preferring named exports because they enable better tooling support. IDEs can auto-import named exports by name, and renaming a named export shows all files that need updating. Default exports make this harder since each importer chooses their own name.
Dynamic Imports
Static import statements are hoisted and evaluated before any code runs. Sometimes you need to load a module conditionally or lazily — that's where dynamic imports come in.
Dynamic imports use import() as a function that returns a Promise. They can appear anywhere in your code, not just at the top level.
// Basic dynamic import — returns a Promise
async function loadChart() {
const { Chart } = await import('./Chart.js');
return new Chart('#canvas', { type: 'bar', data });
}
// Conditional loading — only load if needed
async function handleExport(format) {
if (format === 'pdf') {
const { generatePDF } = await import('./pdf-generator.js');
return generatePDF(data);
} else if (format === 'csv') {
const { generateCSV } = await import('./csv-generator.js');
return generateCSV(data);
}
}
// Lazy-load on user interaction
button.addEventListener('click', async () => {
const module = await import('./heavy-feature.js');
module.default.init(); // default export
});
// Dynamic import with variable path (be careful — bundlers
// can't statically analyze these for tree-shaking)
const locale = navigator.language.split('-')[0];
const { messages } = await import(`./locales/${locale}.js`);
// Promise.all for parallel loading
const [{ Chart }, { DataTable }] = await Promise.all([
import('./Chart.js'),
import('./DataTable.js'),
]);
Code Splitting with Dynamic Imports
Bundlers like Vite and webpack treat every dynamic import() call as a split point. The dynamically imported module gets its own chunk file and is only downloaded when that import() call executes at runtime. This is how React's React.lazy() works under the hood:
import React, { Suspense, lazy } from 'react';
// Each of these becomes a separate bundle chunk
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
const Analytics = lazy(() => import('./Analytics'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Router>
<Route path="/dashboard" component={Dashboard} />
<Route path="/settings" component={Settings} />
<Route path="/analytics" component={Analytics} />
</Router>
</Suspense>
);
}
// Result: users only download the code for the page they visit
Advanced Module Patterns
Barrel Exports (Index Files)
A barrel file re-exports from multiple modules through a single entry point. This is common in React component libraries and feature folders — instead of importing from deep paths, consumers import from the directory.
// components/Button/Button.jsx
export default function Button({ children, onClick }) { /* ... */ }
// components/Modal/Modal.jsx
export default function Modal({ isOpen, children }) { /* ... */ }
// components/Input/Input.jsx
export { Input, TextArea, Select };
// components/index.js — barrel file
export { default as Button } from './Button/Button';
export { default as Modal } from './Modal/Modal';
export { Input, TextArea, Select } from './Input/Input';
// Now consumers import from one place:
import { Button, Modal, Input } from './components';
// Instead of:
import Button from './components/Button/Button';
import Modal from './components/Modal/Modal';
Barrel files can hurt build performance in large codebases because bundlers must parse the entire barrel to determine what's used. If you import just Button, the bundler still processes all re-exports. For large component libraries, consider importing directly from the source file. Vite and webpack's tree-shaking mitigate this, but it's worth measuring.
Re-exports and Module Aggregation
// Re-export everything from another module
export * from './math.js';
// Re-export with rename (avoids naming conflicts)
export { add as mathAdd } from './math.js';
// Re-export a default as named
export { default as Calculator } from './Calculator.js';
// Re-export a named as default
export { add as default } from './math.js';
// Re-export everything EXCEPT default
export * from './utils.js'; // named only — doesn't include default
Circular Dependencies
ES modules handle circular dependencies (A imports B which imports A) through live bindings: the import is a reference, not a copy. By the time the circular reference is actually called (not just declared), both modules are fully initialized. However, circular dependencies are still a code smell — they make the dependency graph harder to reason about.
Understanding Live Bindings
Unlike CommonJS where require() returns a snapshot copy of the exports, ES module imports are live read-only bindings. If the exporting module changes the exported value, all importers see the updated value immediately:
// counter.js
export let count = 0;
export function increment() { count++; }
// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 ← live binding reflects the change
// With CommonJS this would NOT work — you'd get a stale copy:
// const { count } = require('./counter'); // snapshot
// increment();
// console.log(count); // still 0 in CommonJS!
Setting Up ES Modules in Node.js
Node.js supports ES modules natively since v12 (stable in v14). To enable them, you have two options:
Set "type": "module" in package.json
All .js files in the package are treated as ES modules. Use .cjs extension for any CommonJS files.
{
"name": "my-app",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/index.js"
}
}
Use the .mjs file extension
Name your file app.mjs instead of app.js. Node.js treats .mjs files as ES modules regardless of the package.json setting.
Always include file extensions in import paths
Unlike bundlers, Node.js ESM does not add .js automatically. Write import { fn } from './utils.js', not './utils'.
// src/utils.js
export function formatCurrency(amount, currency = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
export const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// src/index.js — must use .js extension
import { formatCurrency, sleep } from './utils.js';
// __dirname and __filename are not available in ESM
// Use import.meta.url instead:
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(formatCurrency(1234.56)); // $1,234.56
await sleep(100);
console.log('Done', __dirname);
Key Takeaways
JavaScript modules transform how you organize and share code. Here's a quick reference for everything covered:
Module System Cheatsheet
- Named exports: Use for utility modules with multiple exports. Import with
{ name }syntax. - Default export: One per module. Best for the module's primary purpose (a class, component, or main function). Import without braces.
- Namespace import:
import * as Namespace from './mod.js'— useful for grouping related utilities. - Dynamic import:
await import('./mod.js')— loads modules on demand, enables code splitting and conditional loading. - Re-exports:
export { x } from './other.js'— build barrel files for clean public APIs. - Live bindings: ES module imports reflect real-time changes to exported values, unlike CommonJS snapshots.
- Node.js ESM: Requires
"type": "module"inpackage.jsonor.mjsextension. Always include file extensions in paths. - Prefer named exports in most cases for better IDE support, refactoring safety, and tree-shaking.
"Modules are not just a code organization tool — they are a contract between parts of your system. Good module boundaries define the architecture of your application."
Understanding ES modules deeply — knowing when to use named vs default exports, when to reach for dynamic imports, and how live bindings differ from CommonJS — puts you in control of your application's structure and performance. Whether you're building a React app with Vite, a Node.js API, or a vanilla JS project, modules are the foundation everything else is built on.