JavaScript Modules

45 min read Intermediate

Organize your code into reusable modules with ES6 import/export syntax, dynamic imports, and module patterns.

Why Modules?

Modules help organize code into separate files, avoid naming conflicts, and make code reusable and maintainable.

JavaScript
// Without modules - global scope pollution
// file1.js
var userName = "John";
function greet() { console.log("Hello"); }

// file2.js
var userName = "Jane"; // Conflict! Overwrites file1.js
function greet() { }   // Conflict!

// With modules - each file has its own scope
// user.js
export const userName = "John";
export function greet() { console.log("Hello"); }

// app.js
import { userName, greet } from "./user.js";
// No conflicts, explicit dependencies
Module Benefits
  • Encapsulation - private by default
  • Explicit dependencies - clear imports
  • Reusability - import anywhere
  • Maintainability - smaller, focused files
  • Tree shaking - remove unused code

Exporting

Export variables, functions, and classes to make them available to other modules.

JavaScript
// ===== Named Exports =====
// math.js

// Export inline
export const PI = 3.14159;

export function add(a, b) {
    return a + b;
}

export function subtract(a, b) {
    return a - b;
}

export class Calculator {
    multiply(a, b) {
        return a * b;
    }
}

// Or export at the end
const E = 2.71828;
function divide(a, b) {
    return a / b;
}

export { E, divide };

// Export with alias
export { divide as div };


// ===== Default Export =====
// Each module can have ONE default export
// logger.js
export default class Logger {
    log(message) {
        console.log(`[LOG] ${message}`);
    }
    
    error(message) {
        console.error(`[ERROR] ${message}`);
    }
}

// Or for functions
// greet.js
export default function greet(name) {
    return `Hello, ${name}!`;
}

// Or for values
// config.js
export default {
    apiUrl: "https://api.example.com",
    timeout: 5000,
    debug: true
};


// ===== Mixed Exports =====
// utils.js
export const VERSION = "1.0.0";
export function helper() { }

export default class Utils {
    static format(value) { }
}

Importing

Import modules to use their exports in your code.

JavaScript
// ===== Named Imports =====
import { add, subtract, PI } from "./math.js";

console.log(add(2, 3));    // 5
console.log(PI);           // 3.14159

// Import with alias
import { add as addition, subtract as sub } from "./math.js";
console.log(addition(2, 3)); // 5

// Import all as namespace
import * as math from "./math.js";
console.log(math.add(2, 3)); // 5
console.log(math.PI);        // 3.14159


// ===== Default Imports =====
import Logger from "./logger.js";
// Can use any name for default imports
import MyLogger from "./logger.js"; // Same thing

const logger = new Logger();
logger.log("Hello");


// ===== Mixed Imports =====
// Import default and named together
import Utils, { VERSION, helper } from "./utils.js";

console.log(VERSION);
Utils.format("test");


// ===== Side-Effect Imports =====
// Import module for its side effects only
import "./polyfills.js";
import "./styles.css"; // With bundlers like webpack


// ===== Import Paths =====
// Relative paths
import { func } from "./utils.js";       // Same directory
import { func } from "../utils.js";      // Parent directory
import { func } from "./lib/utils.js";   // Subdirectory

// Absolute paths (with bundler/config)
import { func } from "@/utils";          // Alias
import { func } from "lodash";           // node_modules
File Extension Required

In browsers and Node.js ES modules, you must include the file extension (e.g., ./utils.js). Bundlers like webpack may allow omitting it.

Dynamic Imports

Load modules on demand for code splitting and lazy loading.

JavaScript
// Dynamic import returns a Promise
const module = await import("./math.js");
console.log(module.add(2, 3));

// With .then()
import("./math.js").then(module => {
    console.log(module.PI);
});

// Conditional loading
if (needsFeature) {
    const { feature } = await import("./feature.js");
    feature();
}

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

// Load based on route
async function loadPage(route) {
    switch (route) {
        case "/home":
            return import("./pages/home.js");
        case "/about":
            return import("./pages/about.js");
        case "/contact":
            return import("./pages/contact.js");
        default:
            return import("./pages/404.js");
    }
}

// Dynamic path (variable)
const lang = "en";
const translations = await import(`./locales/${lang}.js`);

// Import default export
const { default: Component } = await import("./Component.js");

// Error handling
try {
    const module = await import("./might-not-exist.js");
} catch (error) {
    console.error("Failed to load module:", error);
    // Fallback behavior
}

// Preload modules
function preloadModule(path) {
    const link = document.createElement("link");
    link.rel = "modulepreload";
    link.href = path;
    document.head.appendChild(link);
}

preloadModule("./heavy-feature.js");

Re-exporting

Create barrel files to simplify imports from multiple modules.

JavaScript
// utils/index.js - Barrel file

// Re-export everything from a module
export * from "./math.js";
export * from "./string.js";
export * from "./array.js";

// Re-export specific items
export { add, subtract } from "./math.js";
export { capitalize, trim } from "./string.js";

// Re-export with rename
export { add as sum } from "./math.js";

// Re-export default as named
export { default as Logger } from "./Logger.js";
export { default as Formatter } from "./Formatter.js";

// Now import from one place
// app.js
import { 
    add, 
    subtract, 
    capitalize, 
    Logger 
} from "./utils/index.js";

// Or simply
import { add, capitalize } from "./utils";


// ===== Organizing a project =====
// components/index.js
export { Button } from "./Button.js";
export { Modal } from "./Modal.js";
export { Card } from "./Card.js";
export { Input } from "./Input.js";

// Usage
import { Button, Modal, Card } from "./components";


// ===== API module pattern =====
// api/index.js
export { fetchUsers, createUser } from "./users.js";
export { fetchPosts, createPost } from "./posts.js";
export { login, logout } from "./auth.js";

// All API functions in one import
import * as api from "./api";
api.fetchUsers();
api.login();

Modules in Browser

Use ES modules directly in modern browsers.

HTML
<!-- Use type="module" to enable ES modules -->
<script type="module" src="./app.js"></script>

<!-- Inline module -->
<script type="module">
    import { greet } from "./utils.js";
    greet("World");
</script>

<!-- Fallback for older browsers -->
<script type="module" src="./app.js"></script>
<script nomodule src="./app-legacy.js"></script>
JavaScript
// Module behavior differences

// 1. Modules are deferred by default
// (equivalent to defer attribute)

// 2. Modules run in strict mode automatically
// "use strict" is implied

// 3. Top-level 'this' is undefined
console.log(this); // undefined in module, window in script

// 4. Top-level variables are scoped to module
const secret = "hidden"; // Not on window object

// 5. import.meta - module metadata
console.log(import.meta.url); // Full URL of current module

// 6. Modules are loaded once and cached
import "./init.js"; // Runs once
import "./init.js"; // Uses cached version

// 7. CORS required for cross-origin modules
// Modules from other origins need proper CORS headers

CommonJS (Node.js)

Node.js traditionally uses CommonJS modules. Understanding both systems is important.

JavaScript
// ===== CommonJS (Node.js traditional) =====
// math.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

module.exports = { add, subtract };
// or
exports.add = add;
exports.subtract = subtract;

// app.js
const math = require("./math");
console.log(math.add(2, 3));

const { add, subtract } = require("./math");


// ===== ES Modules in Node.js =====
// package.json
{
    "type": "module" // Enable ES modules for .js files
}

// Or use .mjs extension for ES modules
// math.mjs
export const add = (a, b) => a + b;

// app.mjs
import { add } from "./math.mjs";


// ===== Key Differences =====
// CommonJS:
// - require() is synchronous
// - Can use conditionally
// - Variables are copied
// - require is a function call

// ES Modules:
// - import is asynchronous
// - Static (top-level only)*
// - Variables are live bindings
// - import is a declaration

// *Dynamic import() works anywhere

// Interop: Import CommonJS in ES Module
import pkg from "./commonjs-module.cjs";
const { namedExport } = pkg;

// Interop: Require ES Module in CommonJS
// (requires async context)
const module = await import("./es-module.mjs");

Dynamic Imports Deep Dive

Dynamic imports enable code splitting and lazy loading, improving initial load times by loading code only when needed.

Route-Based Code Splitting

JavaScript - Route-Based Loading
// Router with dynamic imports
class Router {
    constructor(routes) {
        this.routes = routes;
        this.cache = new Map();
        
        window.addEventListener('popstate', () => this.navigate());
        this.navigate();
    }
    
    async navigate(path = window.location.pathname) {
        const route = this.routes[path] || this.routes['/404'];
        
        try {
            let module;
            
            // Cache loaded modules
            if (this.cache.has(route)) {
                module = this.cache.get(route);
            } else {
                // Show loading state
                this.showLoader();
                
                // Dynamic import with webpackChunkName (for webpack)
                module = await import(/* webpackChunkName: "[request]" */ route);
                this.cache.set(route, module);
            }
            
            // Render the page component
            const content = await module.default();
            document.getElementById('app').innerHTML = content;
            
        } catch (error) {
            console.error('Route loading failed:', error);
            this.showError();
        } finally {
            this.hideLoader();
        }
    }
    
    showLoader() { /* ... */ }
    hideLoader() { /* ... */ }
    showError() { /* ... */ }
}

// Usage
const router = new Router({
    '/': './pages/Home.js',
    '/about': './pages/About.js',
    '/products': './pages/Products.js',
    '/404': './pages/NotFound.js'
});

Feature-Based Loading

JavaScript - Load Features on Demand
// Load heavy features only when needed
class App {
    async showChart(data) {
        // Load chart library only when user needs charts
        const { Chart } = await import('chart.js');
        
        const chart = new Chart(this.canvas, {
            type: 'bar',
            data: data
        });
        return chart;
    }
    
    async enableRichTextEditor() {
        // Load editor on demand
        const Quill = (await import('quill')).default;
        
        this.editor = new Quill('#editor', {
            theme: 'snow'
        });
    }
    
    async loadPolyfillsIfNeeded() {
        // Conditional polyfill loading
        if (!('IntersectionObserver' in window)) {
            await import('intersection-observer');
        }
        
        if (!('ResizeObserver' in window)) {
            const { ResizeObserver } = await import('@juggle/resize-observer');
            window.ResizeObserver = ResizeObserver;
        }
    }
}

// Prefetch for anticipated navigation
function prefetchModule(path) {
    const link = document.createElement('link');
    link.rel = 'modulepreload';
    link.href = path;
    document.head.appendChild(link);
}

// Prefetch likely next pages
document.querySelectorAll('a').forEach(link => {
    link.addEventListener('mouseenter', () => {
        prefetchModule(getModulePathForLink(link));
    }, { once: true });
});

Tree Shaking

Tree shaking eliminates dead code (unused exports) from your bundles. It relies on ES module static analysis to determine what code is actually used.

Writing Tree-Shakeable Code

JavaScript - Tree Shaking Best Practices
// ✅ Good: Named exports are tree-shakeable
// utils.js
export function formatDate(date) { /* ... */ }
export function formatCurrency(amount) { /* ... */ }
export function formatPhone(phone) { /* ... */ }

// Only formatDate is included in bundle
import { formatDate } from './utils.js';

// ❌ Bad: Default export of object
// utils.js
export default {
    formatDate: () => { /* ... */ },
    formatCurrency: () => { /* ... */ },
    formatPhone: () => { /* ... */ }
};

// Entire object is included
import utils from './utils.js';
utils.formatDate();

// ✅ Good: Named + default for flexibility
// Button.js
export function Button(props) { /* ... */ }
export function IconButton(props) { /* ... */ }
export default Button;

// ✅ Good: Re-export pattern for libraries
// index.js (package entry point)
export { Button, IconButton } from './Button.js';
export { Modal } from './Modal.js';
export { Tooltip } from './Tooltip.js';

// Consumers can import only what they need
import { Button } from 'my-ui-library';

// ❌ Bad: Side effects prevent tree shaking
// If module has side effects, bundler can't remove it
let initialized = false;
export function init() {
    initialized = true;  // Side effect!
}
export function getData() {
    if (!initialized) throw new Error('Not initialized');
    return data;
}

// ✅ Good: Mark side-effect-free in package.json
{
    "name": "my-library",
    "sideEffects": false,
    // Or list specific files with side effects:
    "sideEffects": ["./src/polyfills.js", "*.css"]
}

Circular Dependencies

Circular dependencies occur when two or more modules import each other. While sometimes unavoidable, they can cause subtle bugs and should generally be avoided.

Understanding the Problem

JavaScript - Circular Dependency Issues
// ❌ Circular dependency problem

// a.js
import { b } from './b.js';
export const a = 'Module A';
console.log('A sees b as:', b);

// b.js
import { a } from './a.js';
export const b = 'Module B';
console.log('B sees a as:', a);  // undefined! (a.js hasn't finished executing)

// When you run: node a.js
// Output:
// "B sees a as: undefined"  <- Problem!
// "A sees b as: Module B"

// ✅ Solution 1: Lazy access (access at runtime, not module load)
// a.js
import * as bModule from './b.js';
export const a = 'Module A';
export function getB() {
    return bModule.b;  // Access when called, not when module loads
}

// b.js
import * as aModule from './a.js';
export const b = 'Module B';
export function getA() {
    return aModule.a;  // Now works!
}

Breaking Circular Dependencies

JavaScript - Solutions to Circular Dependencies
// ✅ Solution 2: Extract shared code to third module

// Before (circular):
// user.js imports post.js, post.js imports user.js

// After:
// shared.js - common types/interfaces
export class Entity { /* ... */ }

// user.js
import { Entity } from './shared.js';
export class User extends Entity { /* ... */ }

// post.js  
import { Entity } from './shared.js';
export class Post extends Entity { /* ... */ }


// ✅ Solution 3: Dependency injection

// Before (circular)
// logger.js
import { config } from './config.js';
export class Logger { /* uses config */ }

// config.js
import { Logger } from './logger.js';
export const config = { /* uses Logger */ };

// After (no circular)
// logger.js - no imports from config
export class Logger {
    constructor(config) {
        this.config = config;  // Injected!
    }
}

// config.js
import { Logger } from './logger.js';
export const config = { level: 'info' };
export const logger = new Logger(config);  // Inject config


// ✅ Solution 4: Factory pattern

// registry.js - central registry
const registry = new Map();

export function register(name, factory) {
    registry.set(name, factory);
}

export function get(name) {
    const factory = registry.get(name);
    return factory ? factory() : null;
}

// moduleA.js
import { register, get } from './registry.js';
register('A', () => ({
    doSomething() {
        const B = get('B');  // Get B lazily
        return B.doSomethingElse();
    }
}));

// moduleB.js
import { register, get } from './registry.js';
register('B', () => ({
    doSomethingElse() {
        return 'Hello from B';
    }
}));
Detecting Circular Dependencies
  • ESLint: Use eslint-plugin-import with import/no-cycle rule
  • Webpack: Use circular-dependency-plugin
  • Madge: Visualize dependencies with npx madge --circular src/
  • Signs: Undefined imports at runtime, unexpected initialization order

Summary

Named Export

export { name } - multiple per file

Default Export

export default - one per file

Named Import

import { name } - specific items

Dynamic Import

import() - load on demand

Barrel Files

Re-export for cleaner imports

Browser Usage

<script type="module">