JavaScript ES6 Modules: From Mess to Mastery

If you've ever opened a legacy JavaScript project and found yourself tangled in a web of global variables and mysterious script ordering, you're not alone. Before ES6 modules, JavaScript development was like building a house without walls—everything lived in one global space, and chaos was inevitable.
This guide cuts through the confusion and shows you how modules transform your code from spaghetti into something clean, testable, and maintainable.
The Old Mess: Why We Needed Modules
Picture this: your HTML file looks like a game of order-dependent jenga:
<script src="cart.js"></script>
<script src="user.js"></script>
<script src="payment.js"></script>
Here's what happens:
Global pollution – every variable and function in all three files lives in the global scope
Naming collisions – if
cart.jsanduser.jsboth define acountvariable, one silently overwrites the otherOrder dependency – get the script order wrong, and everything breaks in mysterious ways
No encapsulation – there's no way to mark something as "internal only" Result? Spaghetti code that's hard to debug, test, or refactor.
What Modules Actually Solve
ES6 modules give you four superpowers:
| Problem | Solution |
|---|---|
| Global pollution | Variables stay private to their file unless explicitly exported |
| Hidden dependencies | import statements clearly show what each file needs |
| Manual script ordering | The module system automatically resolves dependencies |
| Live binding | Imported values stay synchronized with their source |
| Dead code elimination | Bundlers can tree-shake unused exports |
Exporting: Two Powerful Patterns
1. Named Exports (Multiple Per File)
Use named exports when a file contains multiple utilities, helpers, or constants:
// utils.js
export const PI = 3.14;
export const E = 2.71;
export function double(x) {
return x * 2;
}
export function triple(x) {
return x * 3;
}
Import only what you need:
import { PI, double } from './utils.js';
console.log(double(5)); // 10
console.log(PI); // 3.14
// triple is NOT imported – not cluttering your namespace
Benefits:
✅ Tree shaking – unused exports are dropped from bundles
✅ Clear intent – you import exactly what you use
✅ Rename on import if needed:
import { double as multiply }
2. Default Export (One Per File)
Use default exports for the primary thing a module provides—a component, class, or main function:
// logger.js
export default function log(msg) {
console.log(`[LOG] ${msg}`);
}
Import with any name you choose:
import myLogger from './logger.js';
import { default as logger } from './logger.js'; // also valid
myLogger('Hello!'); // [LOG] Hello!
When to use:
A React component
A single class
The main factory function in a module
An object you want to use as a namespace
⚠️ Common mistake: Don't export a default object containing 20 helper functions. That defeats tree shaking. Use named exports instead.
The Live Binding Trick (Most Get This Wrong)
Here's something that surprises many developers. Imported values aren't copies—they're live references:
// 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 ← count updated!
The imported count stays synchronized with the original. It's not a one-time snapshot.
Why does this matter?
If you use Vuex, Redux, or similar state managers, this behavior is crucial
Imported state can reliably update across your app
Avoid reassigning imported values (use functions to modify instead)
// ✅ Good: call the function, state updates everywhere
increment();
// ❌ Bad: reassigning breaks the binding
count = 5; // only affects your local variable, not the original
Circular Dependencies? They Work (Carefully)
A common fear: what if a.js imports b.js and b.js imports a.js?
ES6 handles this gracefully:
// a.js
import { greetB } from './b.js';
export function greetA() { return 'Hello from A'; }
export function combined() { return greetA() + ' and ' + greetB(); }
// b.js
import { greetA } from './a.js';
export function greetB() { return 'Hello from B'; }
What happens?
Module
a.jsstarts loading and requestsb.jsModule
b.jsstarts loading and requestsa.jsES6 sees the circle and provides a partial export object to
b.jsb.jsseesgreetAasundefinedinitially, but oncea.jsfinishes, the binding resolves No crash, no infinite loop—just order-aware initialization. (Though if you can avoid circles, do.)
Import Rules: Static or Dynamic
Static Imports (Top Level Only)
The standard way to import must be at the module's top level:
// ✅ Correct
import { foo } from './bar.js';
if (something) {
foo(); // fine to use conditionally
}
// ❌ Syntax error – can't import inside a block
if (something) {
import { foo } from './bar.js';
}
Static imports are analyzed at parse time, so bundlers know exactly what to include.
Dynamic Imports (When You Need Conditionals)
For conditional or runtime loading, use the import() function:
if (userPrefersDarkMode) {
const darkTheme = await import('./themes/dark.js');
applyTheme(darkTheme);
} else {
const lightTheme = await import('./themes/light.js');
applyTheme(lightTheme);
}
import() returns a Promise, so use await or .then():
// Promise style
import('./heavy-module.js').then(module => {
module.doSomething();
});
// Async/await style (cleaner)
async function loadFeature() {
const feature = await import('./feature.js');
feature.initialize();
}
Real-World Example: Refactoring Without Modules → With Modules
Before (Messy Global Scope)
<!-- index.html -->
<script src="config.js"></script>
<script src="api.js"></script>
<script src="ui.js"></script>
<script src="app.js"></script>
// config.js
const API_URL = 'https://api.example.com';
const TIMEOUT = 5000;
// ui.js
function renderCart(items) { ... }
function updateUI(data) { ... }
// api.js
function fetchCart() {
fetch(API_URL + '/cart', { timeout: TIMEOUT })
.then(r => r.json())
.then(data => updateUI(data)); // relies on ui.js existing
}
// app.js
fetchCart(); // relies on api.js existing
Problems: manual ordering, global scope, dependencies hidden.
After (Clean Modules)
<!-- index.html -->
<script type="module" src="app.js"></script>
// config.js
export const API_URL = 'https://api.example.com';
export const TIMEOUT = 5000;
// ui.js
export function renderCart(items) { ... }
export function updateUI(data) { ... }
// api.js
import { API_URL, TIMEOUT } from './config.js';
import { updateUI } from './ui.js';
export async function fetchCart() {
const response = await fetch(API_URL + '/cart', { timeout: TIMEOUT });
const data = await response.json();
updateUI(data);
}
// app.js
import { fetchCart } from './api.js';
fetchCart();
Benefits:
✅ No global variables
✅ Dependencies are explicit in imports
✅ Order doesn't matter (module system handles it)
✅ Dead code elimination works
✅ Easy to test each module in isolation
Key Takeaways
| Concept | Remember |
|---|---|
| Named exports | Use for utilities, constants, helpers. Enables tree shaking. |
| Default export | Use for the main thing a module does. |
| Live bindings | Imported values stay in sync with the original. |
| Static imports | Must be at module top level. Analyzed at parse time. |
| Dynamic imports | Use import() for runtime/conditional loading. Returns a Promise. |
| No manual ordering | Modules resolve their own dependencies automatically. |
Have questions about modules? Struggling with circular dependencies or tree shaking? Drop a comment below, and let's untangle it together.




