DEV Community

Cover image for JavaScript Modules Explained: Import, Export, and Why Your Code Will Thank You
Janmejai Singh
Janmejai Singh

Posted on

JavaScript Modules Explained: Import, Export, and Why Your Code Will Thank You

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>
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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 }     │
                                └──────────────────┘
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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>`;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Use aliases to avoid name conflicts:

import { add as addNumbers } from './math.js';
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

Both at Once

import fetchUser, { BASE_URL, handleError } from './api.js';
Enter fullscreen mode Exit fullscreen mode

Import Everything as a Namespace

import * as MathUtils from './math.js';

MathUtils.add(1, 2);  // 3
MathUtils.PI;          // 3.14159
Enter fullscreen mode Exit fullscreen mode

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) { ... }
Enter fullscreen mode Exit fullscreen mode

⚠️ 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
Enter fullscreen mode Exit fullscreen mode

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    │
└───────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode
// 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;
}
Enter fullscreen mode Exit fullscreen mode
// 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);
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)