JavaScript ES Modules Complete Guide: import, export, and Module Patterns
ES Modules (ESM) are the official JavaScript module system. Understanding them thoroughly prevents common bugs around circular dependencies, tree shaking, and dynamic loading.
Named Exports
// utils/math.js
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
// Can also export at the end (preferred for readability)
const subtract = (a, b) => a - b;
const divide = (a, b) => a / b;
export { subtract, divide };
export { subtract as sub, divide as div }; // Rename on export
// Import named exports
import { add, multiply } from './utils/math.js';
import { subtract as sub } from './utils/math.js'; // Rename on import
import * as MathUtils from './utils/math.js'; // Import everything as namespace
console.log(add(2, 3)); // 5
console.log(MathUtils.multiply(4, 5)); // 20
console.log(sub(10, 3)); // 7
Default Exports
// One default export per file
// components/Button.jsx
export default function Button({ children, onClick }) {
return <button onClick={onClick}>{children}</button>;
}
// Or assign then export
class ApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
get(path) { return fetch(this.baseUrl + path); }
}
export default ApiClient;
// Import default (any name you want)
import Button from './components/Button';
import Api from './utils/ApiClient'; // Different name than export — OK!
import MyButton from './components/Button'; // Also fine
// Combine default and named imports
import React, { useState, useEffect } from 'react';
// ↑ default ↑ named exports
Named vs Default: Which to Use?
// ✅ Use NAMED exports for:
// - Utility functions (multiple exports per file)
// - Constants
// - Types/interfaces
// - Anything that benefits from consistent naming
export const formatDate = (date) => ...;
export const formatCurrency = (amount) => ...;
export const API_URL = 'https://api.example.com';
// ✅ Use DEFAULT exports for:
// - Components (one per file)
// - Classes that represent a clear "thing"
// - Main entry points
export default class UserService { ... }
// ⚠️ Avoid mixing patterns inconsistently
// Pick a convention and stick to it
Re-exporting (Barrel Files)
// utils/index.js — barrel file
export { add, multiply } from './math.js';
export { formatDate, formatCurrency } from './formatting.js';
export { validateEmail, validatePhone } from './validation.js';
// Re-export with rename
export { default as Button } from './Button.jsx';
export { default as Input } from './Input.jsx';
// Re-export everything
export * from './helpers.js';
// Now consumers can import from one place
import { add, formatDate, Button } from './utils';
// Instead of:
// import { add } from './utils/math';
// import { formatDate } from './utils/formatting';
// import Button from './utils/Button';
Dynamic Imports
// Static import: evaluated at parse time
import { heavyLibrary } from './heavy.js'; // Always loaded, even if not used
// Dynamic import: lazy-loaded at runtime
async function loadFeature() {
// Only loads when this function is called
const { heavyLibrary } = await import('./heavy.js');
return heavyLibrary.process(data);
}
// React lazy loading (uses dynamic import under the hood)
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
// Conditional loading
async function loadChart() {
const chartLib = navigator.onLine
? await import('./charts/online')
: await import('./charts/offline');
return chartLib.render(data);
}
// Dynamic import with named exports
const { formatDate } = await import('./utils/formatting.js');
Module Live Bindings
ES Modules export live bindings, not values — a key difference from CommonJS.
// 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! value updated!
// In CommonJS (require), this would still be 0 (copies the value)
ESM vs CommonJS
// CommonJS (Node.js traditional)
const fs = require('fs');
const { readFile } = require('fs');
module.exports = { myFunction };
module.exports.myValue = 42;
// ES Modules
import fs from 'fs';
import { readFile } from 'fs';
export { myFunction };
export const myValue = 42;
| Feature | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| Syntax | require() / module.exports |
import / export |
| Evaluation | Synchronous | Asynchronous |
| Bindings | Copies values | Live bindings |
| Tree shaking | ❌ Difficult | ✅ Native |
| Top-level await | ❌ No | ✅ Yes |
| File extension | .js (default) |
.mjs or "type":"module" |
| Browser support | ❌ Needs bundler | ✅ Native |
| Dynamic | Runtime | Static (mostly) |
Circular Dependencies
// ⚠️ Circular dependency — common source of bugs
// a.js
import { b } from './b.js';
export const a = 'a uses: ' + b; // b might be undefined here!
// b.js
import { a } from './a.js';
export const b = 'b uses: ' + a; // a might be undefined here!
// ESM handles this with live bindings — by the time code runs, bindings resolve
// But initialization order matters!
// ✅ Refactor: extract shared dependency
// shared.js
export const shared = 'shared value';
// a.js
import { shared } from './shared.js';
export const a = 'a: ' + shared;
// b.js
import { shared } from './shared.js';
export const b = 'b: ' + shared;
Tree Shaking
ES Modules enable tree shaking (dead code elimination) because imports are static.
// ✅ Named imports are tree-shakeable
import { formatDate } from 'date-fns';
// Bundler: only includes formatDate, not the whole library
// ❌ CommonJS can't be tree-shaken
const dateFns = require('date-fns');
dateFns.formatDate(new Date()); // Entire library bundled!
// ✅ Write tree-shakeable libraries
// Export each function separately
export function formatDate(date) { ... }
export function addDays(date, days) { ... }
export function startOfMonth(date) { ... }
// ❌ Export object (hard to tree-shake)
export default {
formatDate,
addDays,
startOfMonth,
};
Top-Level Await (ESM Only)
// In ES Modules, you can await at the top level
// config.js
const config = await fetch('/api/config').then(r => r.json());
export const API_URL = config.apiUrl;
export const FEATURE_FLAGS = config.features;
// This pauses the module evaluation until the fetch completes
// All modules that import from config.js wait for it to finish
In Node.js
// Option 1: .mjs extension
// mymodule.mjs
import { readFile } from 'fs/promises';
export async function readConfig(path) {
return JSON.parse(await readFile(path, 'utf8'));
}
// Option 2: "type": "module" in package.json
// package.json
{
"type": "module"
}
// Now all .js files are treated as ESM
// Use .cjs for CommonJS files
// Interop: import CommonJS from ESM
import legacyModule from './legacy.cjs'; // Default import works
→ Convert between JSON and YAML config files with the JSON to YAML Converter.