DocsHub
ES6+

Modules

Learn how to split JavaScript code into reusable files using ES6 modules, import, and export.

Modules

As applications grow, keeping all your JavaScript in one file becomes unmanageable. Hundreds or thousands of lines in a single file is hard to read, hard to debug, and impossible to work on with a team.

Modules let you split your code into separate files — each focused on one thing — and connect them together through import and export.

// math.js — a module that does math
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }

// main.js — imports and uses it
import { add, multiply } from "./math.js";

console.log(add(2, 3));      // 5
console.log(multiply(4, 5)); // 20

One file exports. Another imports. Clean, organized, reusable.


Why Modules Matter

Before modules, JavaScript had no built-in way to split code across files. Everything shared the same global scope — functions, variables, and all. Two files using a variable called data would overwrite each other.

Modules solve this completely. Every module has its own scope. Nothing leaks into the global scope unless you explicitly export it.

main.js utils.js api.js components.js helpers.js

Each file imports only what it needs. Dependencies are explicit and traceable.


Setting Up Modules

To use ES6 modules in a browser, add type="module" to your script tag:

<script type="module" src="main.js"></script>

In Node.js, either use the .mjs extension or add "type": "module" to your package.json.

Module scripts are always deferred — they run after the HTML is parsed. They also run in strict mode automatically.


Named Exports

Export multiple things from one file by name. Each export has a specific name that importers must use.

Exporting inline

// utils.js
export const PI = 3.14159;

export function formatDate(date) {
  return date.toLocaleDateString("en-US");
}

export function capitalize(str) {
  return str[0].toUpperCase() + str.slice(1);
}

export class Timer {
  constructor() {
    this.seconds = 0;
  }
  tick() {
    this.seconds++;
  }
}

Exporting at the bottom

An alternative — define everything first, then export at the end. Many developers prefer this because you can see all exports in one place.

// utils.js
const PI = 3.14159;

function formatDate(date) {
  return date.toLocaleDateString("en-US");
}

function capitalize(str) {
  return str[0].toUpperCase() + str.slice(1);
}

export { PI, formatDate, capitalize };

Named Imports

Import specific named exports using curly braces. The names must match exactly.

// main.js
import { PI, formatDate, capitalize } from "./utils.js";

console.log(PI);                           // 3.14159
console.log(capitalize("hello"));          // Hello
console.log(formatDate(new Date()));       // 3/15/2024

Importing with a different name

Use as to rename an import — useful when names conflict or you want something shorter.

import { capitalize as cap, formatDate as format } from "./utils.js";

console.log(cap("hello"));          // Hello
console.log(format(new Date()));    // 3/15/2024

Importing everything

Import all named exports as a single object using * as:

import * as utils from "./utils.js";

console.log(utils.PI);                    // 3.14159
console.log(utils.capitalize("hello"));   // Hello
console.log(utils.formatDate(new Date())); // 3/15/2024

Default Export

Each module can have one default export — the main thing the module provides. No curly braces needed when importing.

// user.js
export default class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }

  greet() {
    return `Hello, I am ${this.name}.`;
  }
}
// main.js
import User from "./user.js"; // no curly braces — default import

const ali = new User("Ali", "ali@example.com");
console.log(ali.greet()); // Hello, I am Ali.

You can name a default import anything — the name does not have to match:

import Person from "./user.js";    // ✅ valid — different name
import MyUser from "./user.js";   // ✅ valid — any name works

Named and Default Together

A module can have both a default export and named exports.

// api.js
const BASE_URL = "https://api.example.com";

export async function getUser(id) {
  const response = await fetch(`${BASE_URL}/users/${id}`);
  return response.json();
}

export async function getPosts(userId) {
  const response = await fetch(`${BASE_URL}/posts?userId=${userId}`);
  return response.json();
}

export default {
  baseUrl: BASE_URL,
  version: "v1"
};
// main.js
import config, { getUser, getPosts } from "./api.js";

console.log(config.baseUrl); // https://api.example.com
const user = await getUser(1);
const posts = await getPosts(1);

Default import comes first, named imports in curly braces after.


Re-exporting

A module can import from one place and re-export — useful for creating a single entry point that exposes things from multiple files.

// Individual files
// math.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }

// string.js
export function capitalize(str) { return str[0].toUpperCase() + str.slice(1); }
export function truncate(str, n) { return str.slice(0, n) + "..."; }

// array.js
export function unique(arr) { return [...new Set(arr)]; }
export function flatten(arr) { return arr.flat(Infinity); }
// index.js — barrel file, re-exports everything
export { add, subtract } from "./math.js";
export { capitalize, truncate } from "./string.js";
export { unique, flatten } from "./array.js";
// main.js — import from one place instead of three
import { add, capitalize, unique } from "./utils/index.js";

This pattern — a barrel file — is very common in real projects. You import from one clean location instead of hunting down which file has what.


Dynamic Imports

Sometimes you want to import a module on demand — not at the top of the file. Dynamic imports use import() as a function and return a Promise.

// Load a module only when needed
async function loadEditor() {
  const { Editor } = await import("./editor.js");
  const editor = new Editor();
  editor.init();
}

// Load based on a condition
async function loadTheme(themeName) {
  const theme = await import(`./themes/${themeName}.js`);
  applyTheme(theme.default);
}

// Load on user interaction
button.addEventListener("click", async () => {
  const { showModal } = await import("./modal.js");
  showModal();
});

Dynamic imports are lazy — the module is not downloaded until you call import(). This improves initial load time for large applications.

Dynamic imports are the foundation of code splitting in frameworks like React and Vue — loading only the JavaScript a user actually needs instead of everything at once.


Module Scope

Every module has its own scope. Variables declared in a module are private to that module — they do not leak to the global scope.

// counter.js
let count = 0; // private to this module

export function increment() {
  count++;
}

export function getCount() {
  return count;
}
// main.js
import { increment, getCount } from "./counter.js";

increment();
increment();
console.log(getCount()); // 2
console.log(count);      // ❌ ReferenceError — count is private to counter.js

count is completely private. The only way to interact with it is through the exported functions. This is the module pattern — encapsulation built into the language.


Modules Are Singletons

A module is only evaluated once — no matter how many times it is imported. Every importer gets the same instance.

// store.js
export const state = { user: null, theme: "light" };
// a.js
import { state } from "./store.js";
state.user = "Ali";
// b.js
import { state } from "./store.js";
console.log(state.user); // "Ali" — same instance as a.js modified

Both a.js and b.js import the same state object. Changes in one are visible in the other. This is useful for shared state — but also something to be aware of to avoid unexpected behavior.


A Real Example — Project Structure

Here is how a real project might organize its modules:

src/
├── main.js              ← entry point
├── api/
│   ├── index.js         ← barrel file
│   ├── users.js         ← user API calls
│   └── posts.js         ← post API calls
├── utils/
│   ├── index.js         ← barrel file
│   ├── format.js        ← formatting helpers
│   └── validate.js      ← validation helpers
└── components/
    ├── header.js
    └── footer.js
// api/users.js
const BASE_URL = "https://api.example.com";

export async function getUser(id) {
  const res = await fetch(`${BASE_URL}/users/${id}`);
  if (!res.ok) throw new Error(`Failed to fetch user ${id}`);
  return res.json();
}

export async function createUser(data) {
  const res = await fetch(`${BASE_URL}/users`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data)
  });
  if (!res.ok) throw new Error("Failed to create user");
  return res.json();
}
// utils/validate.js
export function isEmail(value) {
  return value.includes("@") && value.includes(".");
}

export function isStrongPassword(value) {
  return value.length >= 8;
}
// api/index.js — barrel
export { getUser, createUser } from "./users.js";
export { getPosts, createPost } from "./posts.js";
// utils/index.js — barrel
export { isEmail, isStrongPassword } from "./validate.js";
export { formatDate, capitalize } from "./format.js";
// main.js — clean imports from barrel files
import { getUser, createUser } from "./api/index.js";
import { isEmail, formatDate } from "./utils/index.js";

async function init() {
  const user = await getUser(1);
  console.log(formatDate(new Date(user.createdAt)));
}

init();

Every file has one clear responsibility. Imports are clean and traceable. Adding a new utility or API function means touching exactly one file.


Summary

  • Modules split code into separate files — each with its own scope
  • Use type="module" in the script tag to enable ES modules in the browser
  • Named exportsexport { name } or export function name() — imported with curly braces
  • Default export — one per module, export default — imported without curly braces
  • Use as to rename imports — import { name as alias }
  • Use * as to import everything as an object — import * as utils
  • Barrel filesindex.js that re-exports from multiple files — keep imports clean
  • Dynamic importsawait import("./module.js") — load modules on demand
  • Modules have their own scope — nothing leaks to global
  • Modules are singletons — evaluated once, the same instance is shared everywhere it is imported

On this page