JavaScriptintermediate

JavaScript Modules & Import/Export — The Complete Guide

Master JavaScript modules: ES Modules vs CommonJS, named and default exports, dynamic imports, tree shaking, circular dependencies, and module best practices.

16 min read·Published Mar 12, 2026
modulesimportsexportsjavascript

Why Modules Exist

Before modules, all JavaScript loaded into a single global scope. Every variable, every function, every library — all shared one namespace.

<!-- The old days -->
<script src="jquery.js"></script>
<script src="lodash.js"></script>
<script src="utils.js"></script>
<script src="app.js"></script>
<!-- Any script can overwrite variables from any other script -->

Problems with this approach:

  1. Naming collisions — two libraries defining the same global variable
  2. Implicit dependencies — no way to know which scripts depend on which
  3. Load order matters — scripts must appear in the correct sequence
  4. No encapsulation — all internal details are exposed globally
  5. No lazy loading — everything loads upfront

Modules solve all of these problems.

ES Modules (ESM)

ES Modules are the official JavaScript module system, standardized in ES2015 and supported in all modern browsers and Node.js.

Basic Export and Import

// math.js — exporting
export function add(a, b) {
  return a + b;
}

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

export const PI = 3.14159;
// app.js — importing
import { add, subtract, PI } from './math.js';

console.log(add(2, 3));      // 5
console.log(subtract(10, 4)); // 6
console.log(PI);              // 3.14159

Using in the Browser

<!-- type="module" enables ES module syntax -->
<script type="module" src="app.js"></script>

<!-- Or inline -->
<script type="module">
  import { add } from './math.js';
  console.log(add(1, 2));
</script>

Module scripts differ from regular scripts:

Feature                 <script>              <script type="module">
────────────────────────────────────────────────────────────────────
Scope                   global                module (isolated)
Strict mode             opt-in                always on
Top-level this          window                undefined
Runs                    immediately           deferred (after DOM)
Loaded                  synchronously         asynchronously
Duplicate loads         runs each time        runs once (cached)
CORS                    not required          required

Named Exports

Named exports let you export multiple values from a module. Each exported value has a name, and importers must use that exact name (or rename it).

Export Styles

// Style 1: Inline export (preferred)
export function formatDate(date) {
  return date.toISOString().split('T')[0];
}

export const MAX_RETRIES = 3;

export class Logger {
  log(msg) { console.log(msg); }
}

// Style 2: Export list (all at once)
function formatDate(date) {
  return date.toISOString().split('T')[0];
}

const MAX_RETRIES = 3;

class Logger {
  log(msg) { console.log(msg); }
}

export { formatDate, MAX_RETRIES, Logger };

Import Styles

// Import specific names
import { formatDate, MAX_RETRIES } from './utils.js';

// Rename on import
import { formatDate as fmtDate, Logger as AppLogger } from './utils.js';
console.log(fmtDate(new Date()));

// Import everything as a namespace
import * as utils from './utils.js';
console.log(utils.formatDate(new Date()));
console.log(utils.MAX_RETRIES);

Renaming Exports

// Rename on export
function internalHelper() { /* ... */ }

export { internalHelper as publicHelper };
// Consumer uses the public name
import { publicHelper } from './module.js';

Default Exports

Each module can have one default export. It represents the "main" thing the module provides.

// Logger.js — default export
export default class Logger {
  constructor(prefix) {
    this.prefix = prefix;
  }

  log(msg) {
    console.log(`[${this.prefix}] ${msg}`);
  }

  error(msg) {
    console.error(`[${this.prefix}] ERROR: ${msg}`);
  }
}
// app.js — import default (any name works)
import Logger from './Logger.js';
import MyLogger from './Logger.js'; // same thing, different local name

const log = new Logger('App');
log.log('Started'); // [App] Started

Default + Named Exports Together

// api.js
export default class ApiClient {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
  }

  async get(path) {
    const res = await fetch(`${this.baseUrl}${path}`);
    return res.json();
  }
}

export const DEFAULT_BASE_URL = 'https://api.example.com';
export const TIMEOUT = 5000;
// Import both
import ApiClient, { DEFAULT_BASE_URL, TIMEOUT } from './api.js';

const client = new ApiClient(DEFAULT_BASE_URL);

Named vs Default — Comparison

Aspect                Named Export              Default Export
─────────────────────────────────────────────────────────────────
Count per module      unlimited                 one
Import syntax         { name }                  anyName
Refactoring           IDE can auto-rename       name can diverge
Discovery             auto-complete works       must know the module
Convention             utilities, constants      primary class/function

Named vs Default — When to Use

// Use DEFAULT when the module has one primary purpose
// user.js
export default class User { /* ... */ }

// Use NAMED when the module exports multiple related items
// validators.js
export function isEmail(str) { /* ... */ }
export function isPhone(str) { /* ... */ }
export function isURL(str) { /* ... */ }

// Use BOTH when there is one primary + supporting items
// config.js
export default function loadConfig() { /* ... */ }
export const DEFAULT_PORT = 3000;
export const DEFAULT_HOST = 'localhost';

Re-exports

Modules can re-export values from other modules, useful for creating barrel files (index files).

// components/Button.js
export function Button(props) { /* ... */ }

// components/Input.js
export function Input(props) { /* ... */ }

// components/Modal.js
export default function Modal(props) { /* ... */ }

// components/index.js — barrel file (re-exports everything)
export { Button } from './Button.js';
export { Input } from './Input.js';
export { default as Modal } from './Modal.js';

// Can also re-export everything
export * from './Button.js';
export * from './Input.js';
// Consumer — clean imports from one path
import { Button, Input, Modal } from './components/index.js';

CommonJS (CJS)

CommonJS is Node.js's original module system. It uses require() and module.exports.

// math.js — CommonJS exports
function add(a, b) {
  return a + b;
}

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

// Export individual values
module.exports.add = add;
module.exports.subtract = subtract;

// Or shorthand
exports.add = add;
exports.subtract = subtract;

// Or export an object
module.exports = { add, subtract };
// app.js — CommonJS require
const { add, subtract } = require('./math.js');
const math = require('./math.js');

console.log(add(2, 3));       // 5
console.log(math.subtract(5, 2)); // 3

ESM vs CommonJS — Comparison

Feature               ES Modules (ESM)         CommonJS (CJS)
─────────────────────────────────────────────────────────────────
Syntax                import/export            require/module.exports
Loading               async (static)           synchronous
Parse time            before execution         at runtime
Top-level await       yes                      no
Tree shaking          yes                      limited
Browser support       native                   needs bundler
Node.js               .mjs or "type":"module"  .cjs or default
Strict mode           always                   opt-in
Live bindings         yes                      no (copies)
Conditional imports   dynamic import()         yes (require in if)

Live Bindings vs Copies

This is a subtle but important difference.

// ESM — live bindings (exports reflect latest value)
// counter.mjs
export let count = 0;
export function increment() {
  count++;
}
// app.mjs
import { count, increment } from './counter.mjs';

console.log(count); // 0
increment();
console.log(count); // 1 (live binding — sees the update)
// CJS — copies (exports are snapshots)
// counter.js
let count = 0;
function increment() {
  count++;
}
module.exports = { count, increment };
// app.js
const { count, increment } = require('./counter.js');

console.log(count); // 0
increment();
console.log(count); // 0 (copy — does NOT see the update)

Dynamic Imports

Static import declarations must be at the top level. Dynamic import() returns a Promise and can be used anywhere, including inside conditionals and loops.

// Static import — always loaded
import { heavyChart } from './charts.js';

// Dynamic import — loaded on demand
async function showChart(type) {
  if (type === 'bar') {
    const { BarChart } = await import('./charts/BarChart.js');
    return new BarChart();
  } else if (type === 'pie') {
    const { PieChart } = await import('./charts/PieChart.js');
    return new PieChart();
  }
}

Code Splitting

Dynamic imports enable code splitting — loading code only when needed.

// Route-based code splitting
const routes = {
  '/': () => import('./pages/Home.js'),
  '/about': () => import('./pages/About.js'),
  '/dashboard': () => import('./pages/Dashboard.js'),
};

async function navigate(path) {
  const loadPage = routes[path];
  if (!loadPage) {
    const { NotFound } = await import('./pages/NotFound.js');
    return new NotFound();
  }

  const module = await loadPage();
  return new module.default();
}

Loading Default and Named Exports

// Default export
const module = await import('./Logger.js');
const Logger = module.default;

// Named exports
const { add, subtract } = await import('./math.js');

// Both
const { default: ApiClient, TIMEOUT } = await import('./api.js');

Dynamic Import Error Handling

async function loadPlugin(name) {
  try {
    const plugin = await import(`./plugins/${name}.js`);
    plugin.default.init();
  } catch (error) {
    if (error.message.includes('Cannot find module')) {
      console.warn(`Plugin "${name}" not found, skipping`);
    } else {
      throw error;
    }
  }
}

Module Patterns

Before ES Modules, developers used patterns to achieve encapsulation.

IIFE (Immediately Invoked Function Expression)

// Creates a private scope — the original module pattern
const Calculator = (function () {
  // Private
  let history = [];

  function addToHistory(operation) {
    history.push(operation);
  }

  // Public (returned object)
  return {
    add(a, b) {
      const result = a + b;
      addToHistory(`${a} + ${b} = ${result}`);
      return result;
    },
    subtract(a, b) {
      const result = a - b;
      addToHistory(`${a} - ${b} = ${result}`);
      return result;
    },
    getHistory() {
      return [...history]; // return copy
    },
  };
})();

Calculator.add(2, 3);       // 5
Calculator.getHistory();     // ['2 + 3 = 5']
// Calculator.history         // undefined (private)
// Calculator.addToHistory    // undefined (private)

Revealing Module Pattern

A variation where you define everything privately, then reveal only what you want.

const UserService = (function () {
  // All private
  const users = [];

  function validate(user) {
    return user.name && user.email;
  }

  function create(user) {
    if (!validate(user)) {
      throw new Error('Invalid user');
    }
    users.push(user);
    return user;
  }

  function findById(id) {
    return users.find((u) => u.id === id);
  }

  function getAll() {
    return [...users];
  }

  // Reveal public API
  return {
    create,
    findById,
    getAll,
    // validate is NOT exposed
  };
})();

These patterns are mostly historical now — ES Modules provide native encapsulation. But you will see them in older codebases and libraries.

Tree Shaking

Tree shaking is the process of eliminating dead (unused) code from bundles. It relies on the static structure of ES Modules.

// utils.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function multiply(a, b) { return a * b; }
export function divide(a, b) { return a / b; }
// plus 50 more functions...
// app.js — only imports add
import { add } from './utils.js';
console.log(add(1, 2));

With tree shaking, the bundler (webpack, Rollup, esbuild) sees that only add is used and removes subtract, multiply, divide, and all 50 other functions from the final bundle.

Why ESM Enables Tree Shaking

ES Modules (static):
  - imports/exports are determined at parse time
  - bundler can analyze the dependency graph
  - unused exports are safely removed

CommonJS (dynamic):
  - require() can be called conditionally
  - exports can be computed at runtime
  - bundler cannot safely determine what is used
// ESM — bundler knows exactly what is used
import { add } from './math.js'; // static, analyzable

// CJS — bundler cannot be sure what is used
const math = require('./math.js'); // entire module loaded
const fn = math[someVariable];    // dynamic access — cannot tree shake

Writing Tree-Shakeable Code

// GOOD — named exports, tree-shakeable
export function formatDate(date) { /* ... */ }
export function formatCurrency(amount) { /* ... */ }
export function formatPercent(value) { /* ... */ }

// BAD — default object export, NOT tree-shakeable
export default {
  formatDate(date) { /* ... */ },
  formatCurrency(amount) { /* ... */ },
  formatPercent(value) { /* ... */ },
};

// BAD — class with methods, NOT tree-shakeable
export default class Formatter {
  formatDate(date) { /* ... */ }
  formatCurrency(amount) { /* ... */ }
  formatPercent(value) { /* ... */ }
}

Side Effects

A module has side effects if importing it changes global state.

// HAS side effects — importing this changes global state
import './polyfills.js'; // patches Array.prototype
import './analytics.js'; // starts tracking

// NO side effects — only exports values
import { add } from './math.js';

In package.json, you can declare that your package is side-effect-free:

{
  "name": "my-utils",
  "sideEffects": false
}

Or specify which files have side effects:

{
  "sideEffects": ["./src/polyfills.js", "*.css"]
}

This helps bundlers aggressively tree-shake your code.

Circular Dependencies

A circular dependency occurs when module A imports from module B, and module B imports from module A.

// a.js
import { b } from './b.js';
export const a = `a depends on ${b}`;

// b.js
import { a } from './a.js';
export const b = `b depends on ${a}`;

How ESM Handles Circular Dependencies

ES Modules handle circulars through live bindings, but the values may be uninitialized when first accessed.

// parent.js
import { childValue } from './child.js';
export const parentValue = 'parent';
console.log('parent sees child:', childValue);

// child.js
import { parentValue } from './parent.js';
export const childValue = 'child';
console.log('child sees parent:', parentValue);

The output depends on which module is the entry point:

If parent.js is entry:
  1. parent.js starts loading, sees import from child.js
  2. child.js starts executing
  3. child.js tries to read parentValue -> undefined (not yet initialized)
  4. child.js finishes: "child sees parent: undefined"
  5. parent.js finishes: "parent sees child: child"

Avoiding Circular Dependencies

// Strategy 1: Extract shared code into a third module
// shared.js (no imports from a.js or b.js)
export const sharedConfig = { /* ... */ };

// a.js
import { sharedConfig } from './shared.js';

// b.js
import { sharedConfig } from './shared.js';
// Strategy 2: Use dependency injection
// Instead of importing directly, accept dependencies as parameters

// bad — circular
// userService.js
import { orderService } from './orderService.js';
export function getUserOrders(userId) {
  return orderService.getByUserId(userId);
}

// good — injection
// userService.js
export function createUserService(orderService) {
  return {
    getUserOrders(userId) {
      return orderService.getByUserId(userId);
    },
  };
}
// Strategy 3: Lazy imports with dynamic import()
// a.js
export async function doSomething() {
  const { helperFromB } = await import('./b.js');
  return helperFromB();
}

Detecting Circular Dependencies

Circular dependencies are often a sign of poor module architecture. Use tools to detect them:

# madge — dependency graph tool
npx madge --circular src/

# eslint plugin
# .eslintrc
# { "rules": { "import/no-cycle": "error" } }

Module Best Practices

1. One Purpose Per Module

// BAD — does too many things
// utils.js
export function formatDate() { /* ... */ }
export function fetchUser() { /* ... */ }
export function validateEmail() { /* ... */ }
export function sortArray() { /* ... */ }

// GOOD — focused modules
// formatters.js
export function formatDate() { /* ... */ }
export function formatCurrency() { /* ... */ }

// validators.js
export function validateEmail() { /* ... */ }
export function validatePhone() { /* ... */ }

2. Prefer Named Exports

// Enables tree shaking and IDE auto-imports
export function processOrder(order) { /* ... */ }
export function validateOrder(order) { /* ... */ }
export function formatOrder(order) { /* ... */ }

3. Use Barrel Files Thoughtfully

// features/auth/index.js
export { LoginForm } from './LoginForm.js';
export { useAuth } from './useAuth.js';
export { AuthProvider } from './AuthProvider.js';

// Consumer
import { LoginForm, useAuth } from './features/auth';

But beware — barrel files can hurt tree shaking if they re-export everything:

// BAD — importing one thing loads all components
export * from './HeavyChart.js';
export * from './DataTable.js';
export * from './Map3D.js';
// If consumer imports only HeavyChart, DataTable and Map3D may still be bundled

// GOOD — import directly for better tree shaking
import { HeavyChart } from './features/charts/HeavyChart.js';

4. Avoid Side Effects at Module Level

// BAD — side effect on import
let instance;
export function getInstance() {
  return instance;
}
instance = createConnection(); // runs when module is imported

// GOOD — explicit initialization
let instance;
export function initialize() {
  instance = createConnection();
}
export function getInstance() {
  if (!instance) throw new Error('Not initialized');
  return instance;
}

5. Keep Import Sections Organized

// External/third-party imports
import React from 'react';
import { useQuery } from '@tanstack/react-query';

// Internal absolute imports
import { Button } from '@/components/Button';
import { useAuth } from '@/hooks/useAuth';

// Relative imports
import { validateForm } from './validators';
import { FORM_FIELDS } from './constants';

// Type imports
import type { FormData, UserProfile } from './types';

// Style imports
import './styles.css';

6. Use package.json "exports" Field

Control what consumers can import from your package:

{
  "name": "my-library",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js"
    },
    "./utils": {
      "import": "./dist/esm/utils.js",
      "require": "./dist/cjs/utils.js"
    }
  }
}
// Consumers can import from defined entry points
import { something } from 'my-library';
import { helper } from 'my-library/utils';

// But NOT from internal paths
// import { internal } from 'my-library/dist/esm/internal.js'; // error

Node.js Module Resolution

Node.js determines whether to use ESM or CJS based on several signals:

Signal                                  Module System
────────────────────────────────────────────────────────
.mjs extension                          ESM
.cjs extension                          CJS
.js + "type": "module" in package.json  ESM
.js + no "type" field                   CJS
.js + "type": "commonjs"                CJS
// package.json — opt entire project into ESM
{
  "type": "module"
}
// With "type": "module", .js files use ESM syntax
import { something } from './module.js'; // ESM

// Use .cjs extension for CommonJS files in an ESM project
// legacy.cjs
const fs = require('fs'); // CJS

Module Loading Lifecycle

Understanding how modules load helps debug timing issues.

Static Import Lifecycle:

  1. Parse         Module source is parsed to find import/export statements
  2. Instantiate   Module graph is built, bindings are created (but not initialized)
  3. Evaluate      Code runs top to bottom, bindings get their values

  Entry ──parse──> A ──parse──> B ──parse──> C
  C evaluates first <────────────────────────┘
  B evaluates second
  A evaluates last
  Entry evaluates last

Dynamic Import Lifecycle:

  1. import() is called at runtime
  2. Module is fetched, parsed, instantiated, evaluated
  3. Promise resolves with the module namespace object
// Observing evaluation order
// a.js
console.log('a.js evaluating');
import './b.js';
console.log('a.js done');

// b.js
console.log('b.js evaluating');
import './c.js';
console.log('b.js done');

// c.js
console.log('c.js evaluating');
console.log('c.js done');

// Output when a.js is the entry:
// c.js evaluating
// c.js done
// b.js evaluating
// b.js done
// a.js evaluating
// a.js done

Key Takeaways

  • ES Modules are the standard — use import/export for all new code
  • Named exports enable tree shaking and IDE discovery; prefer them over default exports
  • Default exports work best when a module has one primary purpose
  • Dynamic import() enables code splitting and lazy loading
  • ESM is static (analyzed at parse time); CJS is dynamic (resolved at runtime)
  • ESM has live bindings (exported values update); CJS has copies (snapshot at require time)
  • Tree shaking eliminates unused code — write side-effect-free modules with named exports for best results
  • Circular dependencies cause initialization issues — restructure with shared modules or dependency injection
  • Organize imports: external first, internal next, relative last, types and styles at the end
  • Use "type": "module" in package.json to enable ESM in Node.js projects

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles