Skip to main content

Command Palette

Search for a command to run...

JavaScript ES6 Modules: From Mess to Mastery

Updated
7 min read
JavaScript ES6 Modules: From Mess to Mastery
P
Backend developer exploring AI agents, backend systems, and architectural rabbit holes. I enjoy understanding how things work under the hood and occasionally over-engineering side projects for fun

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.js and user.js both define a count variable, one silently overwrites the other

  • Order 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.js starts loading and requests b.js

  • Module b.js starts loading and requests a.js

  • ES6 sees the circle and provides a partial export object to b.js

  • b.js sees greetA as undefined initially, but once a.js finishes, 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.