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)); // 20One 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.
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/2024Importing 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/2024Importing 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/2024Default 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 worksNamed 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.jscount 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 modifiedBoth 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 exports —
export { name }orexport function name()— imported with curly braces - Default export — one per module,
export default— imported without curly braces - Use
asto rename imports —import { name as alias } - Use
* asto import everything as an object —import * as utils - Barrel files —
index.jsthat re-exports from multiple files — keep imports clean - Dynamic imports —
await 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