Stop writing 1,400-line JavaScript files.
I've been there. You come back to a project after three weeks. One file. Everything in it. Functions calling other functions. Variables defined on line 847, used on line 12. You scroll up. You scroll down. You question your career choices.
That's the problem ES Modules solve. And they do it elegantly.
Let's get into it.
Why Modules Exist (The Problem First)
Before ES Modules were standard, JavaScript files shared a single global scope via <script> tags:
<script src="utils.js"></script>
<script src="cart.js"></script>
<script src="app.js"></script>
Every variable from every file lived in the same global bucket. The consequences were predictable:
// utils.js
var discount = 0;
// cart.js — loaded after utils.js
var discount = 0.15; // 💥 silently overwrote utils.js's variable
// app.js
console.log(discount); // 0.15 — but was this intentional?
This is global scope pollution. Files had no walls. Anything could overwrite anything. Debugging was archaeology.
The "solution" some teams used was dumping everything into one giant file. That solved collisions but created a different nightmare: no separation of concerns, no reusability, and merge conflict hell when multiple people edited it.
ES Modules fix both problems at once.
What Is an ES Module?
An ES Module is just a JavaScript file that uses import and export statements. Each module file gets its own private scope — variables and functions defined inside don't leak out unless you explicitly export them.
Before modules: After modules:
Global Scope file-a.js (own scope)
┌────────────────┐ ┌──────────────────┐
│ var a = 1; │ │ const a = 1; │
│ var b = 2; │ │ export { a } │
│ function c(){} │ └──────────────────┘
│ function d(){} │
└────────────────┘ file-b.js (own scope)
↑ everything from ┌──────────────────┐
all files lands here │ const b = 2; │
│ export { b } │
└──────────────────┘
Exporting: Making Things Available
Named Exports
Named exports let you share multiple things from a single file:
// math.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export const PI = 3.14159;
Or declare everything first, then export at the bottom (cleaner for larger files):
// math.js
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
const PI = 3.14159;
export { add, subtract, PI };
Both styles do the same thing.
Default Exports
Default exports are for when a file has one primary thing to offer:
// UserCard.js
export default function UserCard({ name, avatar }) {
return `<div class="card">${name}</div>`;
}
A module can only have one default export. It represents "what this file is about."
Mixing Both
You can have one default and multiple named exports in the same file:
// api.js
export default async function fetchUser(id) {
const res = await fetch(`/users/${id}`);
return res.json();
}
export const BASE_URL = "https://api.example.com";
export function handleError(err) {
console.error(err.message);
}
Importing: Pulling What You Need
Named Imports (curly braces, exact names)
import { add, PI } from './math.js';
console.log(add(2, 3)); // 5
console.log(PI); // 3.14159
Use aliases to avoid name conflicts:
import { add as addNumbers } from './math.js';
Default Imports (no curly braces, any name)
import UserCard from './UserCard.js';
// You can name it anything — it's the default export
import Card from './UserCard.js';
import MyCard from './UserCard.js';
Both at Once
import fetchUser, { BASE_URL, handleError } from './api.js';
Import Everything as a Namespace
import * as MathUtils from './math.js';
MathUtils.add(1, 2); // 3
MathUtils.PI; // 3.14159
Default vs Named: The Decision Framework
This trips people up. Here's when to use which:
| Scenario | Export type |
|---|---|
| File represents one thing (component, class, main function) | Default |
| File provides multiple utilities | Named |
| You want better editor autocomplete | Named (IDEs resolve these more reliably) |
| You're building a shared library | Named (enables tree shaking) |
| React component files | Default (community convention) |
A simple test: if you'd describe a file as "the ___ module" (singular), use default. If you'd say "a collection of ___ utilities," use named.
// ✅ Default — this file IS the AuthService
export default class AuthService { ... }
// ✅ Named — this file PROVIDES multiple string utils
export function capitalize(str) { ... }
export function truncate(str, len) { ... }
export function slugify(str) { ... }
⚠️ Red flag: if you write
export default { util1, util2, util3 }— switch to named exports.
File Dependency Diagram
Here's what a modular project's dependency graph looks like:
main.js
├── App.js
│ ├── Header.js
│ │ └── logo.js
│ ├── Cart.js
│ │ ├── CartItem.js
│ │ └── formatCurrency.js ◄──────────────┐
│ └── Checkout.js │
│ ├── validateForm.js │
│ └── formatCurrency.js ────────────────┘
└── router.js
└── routes.js
formatCurrency.js is imported by two separate files. Written once, used everywhere, no duplication. That's the payoff of modular code.
Module Import/Export Flow
┌───────────────────────────────────────────┐
│ math.js │
│ │
│ function add(a, b) { return a + b } │
│ function subtract(a, b) { return a-b } │
│ const PI = 3.14159 │
│ │
│ export { add, subtract, PI } ←── public API
└───────────────────────────────────────────┘
│
│ module system resolves path
▼
┌───────────────────────────────────────────┐
│ app.js │
│ │
│ import { add, PI } from './math.js' │
│ │
│ // subtract stays private in math.js │
│ // only add and PI cross the boundary │
└───────────────────────────────────────────┘
You decide what's public. Everything else is encapsulated by default.
Benefits of Modular Code
1. Encapsulation
Implementation details stay private. Internal helper functions in calculateTax.js don't pollute global scope.
2. Reusability
Write formatDate.js once, import it in ten projects. No copy-pasting, no drift between versions.
3. Maintainability
When something breaks in payment processing, you open payment.js. Not a 3,000-line monolith. You know where to look.
4. Testability
Modules are isolated, which makes them easy to unit test:
// calculateTax.test.js
import { calculateTax } from './calculateTax.js';
test('applies 18% tax correctly', () => {
expect(calculateTax(100, 0.18)).toBe(118);
});
5. Team Scalability
Two developers can work on Cart.js and Checkout.js simultaneously without touching the same file.
6. Tree Shaking
Bundlers like Vite analyze named imports and remove unused code from production builds. This only works with ES Module named exports — another reason to prefer them for libraries.
Using Modules in the Browser
No bundler required for simple projects. Just add type="module":
<script type="module" src="main.js"></script>
Key behaviors with type="module":
- Runs in strict mode automatically
- Deferred by default — won't block HTML parsing
- Each file has its own scope (that's the whole point)
- Requires a local server during dev — can't run from
file://
Real-World Example: E-Commerce Cart
Let's put it all together:
// utils/currency.js
export function formatCurrency(amount, currency = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency
}).format(amount);
}
export function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
// utils/tax.js
export const TAX_RATES = {
standard: 0.18,
reduced: 0.05
};
export function applyTax(amount, rate = TAX_RATES.standard) {
return amount + amount * rate;
}
// Cart.js
import { formatCurrency, calculateTotal } from './utils/currency.js';
import { applyTax } from './utils/tax.js';
export default class Cart {
constructor() {
this.items = [];
}
add(item) {
this.items.push(item);
}
getTotal() {
const subtotal = calculateTotal(this.items);
const withTax = applyTax(subtotal);
return formatCurrency(withTax);
}
}
// main.js
import Cart from './Cart.js';
const cart = new Cart();
cart.add({ name: 'Mechanical Keyboard', price: 89.99, qty: 1 });
cart.add({ name: 'USB-C Hub', price: 34.99, qty: 2 });
console.log(cart.getTotal()); // "$189.48"
Each file has exactly one job. currency.js formats money. tax.js handles tax logic. Cart.js manages cart state. main.js wires it together.
Quick Reference Cheat Sheet
// ─── EXPORTS ──────────────────────────────────
export const name = 'value'; // named inline
export function doThing() {} // named inline
export { name, doThing }; // named grouped
export { doThing as performAction }; // named with alias
export default function mainThing() {} // default
// ─── IMPORTS ──────────────────────────────────
import { name, doThing } from './mod.js'; // named
import { doThing as action } from './mod.js'; // named with alias
import mainThing from './mod.js'; // default
import mainThing, { name } from './mod.js'; // default + named
import * as Mod from './mod.js'; // namespace
import './setup.js'; // side-effect only
Key Takeaways
- Every JS file can be a module — it just needs
import/export - Named exports → multiple things from one file (use curly braces to import)
- Default exports → one main thing per file (no curly braces when importing)
- Modules give files private scope by default — nothing leaks unless you export it
- Modular code is easier to test, debug, maintain, and reuse
- No bundler required for modern browsers — just
type="module"on your script tag
The shift from global-scope chaos to modular code is one of the best investments you can make in a codebase. Start small — pick one responsibility, extract it into its own file, export it, import it where needed. Repeat until your codebase has a shape you're proud of.
If this clicked for you, consider leaving a ❤️ or sharing it with someone learning JavaScript. Got questions or a different way you organize your modules? Drop a comment below — I'd love to hear your approach.
Top comments (0)