Frontend

JavaScript Modules: Import and Export

Mayur Dabhi
Mayur Dabhi
May 17, 2026
14 min read

JavaScript modules are the backbone of modern web development. They let you split your code into reusable, self-contained pieces — each with its own scope — then stitch them together with a clean import/export syntax. Before ES6 introduced the native module system in 2015, developers juggled script tags, global variables, and third-party solutions like RequireJS just to share code between files. Today, ES modules (ESM) are supported natively in all modern browsers and Node.js, making modular JavaScript the standard rather than the exception.

Why Modules Matter

Modules enforce encapsulation: every variable and function declared in a module is private by default. Only what you explicitly export is accessible to the outside world. This single property eliminates an entire class of bugs caused by accidental global state mutation.

Life Before ES Modules

To appreciate ES modules, it helps to understand the pain points they replaced. In the early days of JavaScript, the only way to share code across files was to load multiple <script> tags in the right order and rely on global variables. This approach had serious problems:

The community invented workarounds. The Immediately Invoked Function Expression (IIFE) pattern created a private scope, and later CommonJS (used in Node.js) and AMD (RequireJS) brought module systems to the browser — but they required build tools or runtime loaders.

The Old Ways (before ES modules)
// IIFE pattern — manual encapsulation
const MathUtils = (function() {
    const PI = 3.14159; // private

    function circleArea(r) {
        return PI * r * r;
    }

    return { circleArea }; // public API
})();

// CommonJS (Node.js) — require/module.exports
const { circleArea } = require('./math-utils');
module.exports = { circleArea, squareArea };

// AMD (RequireJS) — define/require
define(['jquery'], function($) {
    return { render: function() { /* ... */ } };
});
CommonJS vs ESM

CommonJS (require/module.exports) is still the default in Node.js for .js files. ES modules use import/export and require either .mjs extension, "type": "module" in package.json, or a bundler like Vite or webpack. Mixing them has nuances — be aware of your runtime environment.

ES6 Module Fundamentals

ES modules bring a standardized, static module system to JavaScript. "Static" means the dependency graph is resolved at parse time, before any code runs — enabling tree-shaking, circular dependency detection, and better tooling support.

main.js Entry Point utils.js exports helpers api.js exports fetchers config.js exports constants http.js shared dependency import statements import import dependency

ES Module dependency graph — resolved statically at parse time

Using Modules in the Browser

To use ES modules in the browser, add type="module" to your script tag. This changes several behaviors: the script is deferred by default, has its own scope, and can use import/export.

index.html
<!-- Classic script: global scope, no import/export -->
<script src="app.js"></script>

<!-- Module script: private scope, supports import/export -->
<script type="module" src="app.js"></script>

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

Exporting from a Module

There are two kinds of exports in ES modules: named exports and default exports. Understanding the difference is fundamental — they serve different purposes and have different import syntax.

Named Exports

Named exports let you export multiple values from a single module. Each export has an explicit name that the importing module must know.

math.js — Named Exports
// Export declarations inline
export const PI = 3.14159265;

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; }
    divide(a, b) {
        if (b === 0) throw new Error('Division by zero');
        return a / b;
    }
}

// OR: export at the end (grouped export list)
const GRAVITY = 9.81;
function square(n) { return n * n; }
function cube(n) { return n * n * n; }

export { GRAVITY, square, cube };

// Export with rename
export { square as pow2, cube as pow3 };

Default Exports

Each module can have at most one default export. Default exports are typically used for the primary thing a module provides — a class, a component, or a main function.

UserService.js — Default Export
// Default export: a class
export default class UserService {
    constructor(apiUrl) {
        this.apiUrl = apiUrl;
    }

    async getUser(id) {
        const response = await fetch(`${this.apiUrl}/users/${id}`);
        if (!response.ok) throw new Error('User not found');
        return response.json();
    }

    async createUser(data) {
        const response = await fetch(`${this.apiUrl}/users`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(data),
        });
        return response.json();
    }
}

// Default export: a function
export default function formatDate(date) {
    return new Intl.DateTimeFormat('en-US', {
        year: 'numeric',
        month: 'long',
        day: 'numeric',
    }).format(new Date(date));
}

// Default export: an anonymous expression
export default {
    apiUrl: 'https://api.example.com',
    timeout: 5000,
    retries: 3,
};

Importing from a Module

The import syntax mirrors the export syntax — named imports use curly braces, while default imports use any name you choose.

Import specific named exports using destructuring-like syntax:

import { add, subtract, PI } from './math.js';

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

// Rename on import to avoid conflicts or for clarity
import { square as pow2, cube as pow3 } from './math.js';
console.log(pow2(4));      // 16
console.log(pow3(2));      // 8

Import the default export with any name you choose — no curly braces:

// The name you give a default import is entirely up to you
import UserService from './UserService.js';
import formatDate from './formatDate.js';

const service = new UserService('https://api.example.com');
const user = await service.getUser(1);

console.log(formatDate(user.createdAt));
// "January 15, 2026"

// You can name it whatever makes sense in context
import getUser from './UserService.js'; // also valid

Import everything from a module as a single namespace object:

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

console.log(MathUtils.add(5, 3));     // 8
console.log(MathUtils.PI);            // 3.14159265
console.log(MathUtils.GRAVITY);       // 9.81

// Useful for utility modules with many exports
import * as validators from './validators.js';

if (validators.isEmail(input)) {
    // ...
}

// Note: the namespace object is live — it reflects
// current export values if they change (rare in practice)

Import both the default and named exports in one statement:

// A module can have both a default AND named exports
// utils.js
export default function log(msg) { console.log(msg); }
export const VERSION = '2.0.0';
export function warn(msg) { console.warn(msg); }

// Import default first, then named in braces
import log, { VERSION, warn } from './utils.js';

log('App started');        // default
warn('Low memory');        // named
console.log(VERSION);     // named: '2.0.0'

// Rename the default import too
import myLogger, { VERSION as v } from './utils.js';

Named vs Default: Which to Use?

This is one of the most debated questions in JavaScript module design. Both are valid — the choice depends on what the module represents.

Aspect Named Export Default Export
Quantity per module Many One
Import syntax import { name } import anyName
Import name Must match export name (or alias) Any name you choose
Refactoring safety High — IDEs can find all usages Lower — names can drift between files
Best for Utility modules, constants, multiple related items A single class, component, or main function
Tree-shaking Excellent — only used exports are bundled Good — whole default is included
Common in Utility libraries (lodash-es, date-fns) React components, Vue SFCs, services
Best Practice

Many style guides (including the Airbnb JavaScript Style Guide) recommend preferring named exports because they enable better tooling support. IDEs can auto-import named exports by name, and renaming a named export shows all files that need updating. Default exports make this harder since each importer chooses their own name.

Dynamic Imports

Static import statements are hoisted and evaluated before any code runs. Sometimes you need to load a module conditionally or lazily — that's where dynamic imports come in.

Dynamic imports use import() as a function that returns a Promise. They can appear anywhere in your code, not just at the top level.

Dynamic Import Examples
// Basic dynamic import — returns a Promise
async function loadChart() {
    const { Chart } = await import('./Chart.js');
    return new Chart('#canvas', { type: 'bar', data });
}

// Conditional loading — only load if needed
async function handleExport(format) {
    if (format === 'pdf') {
        const { generatePDF } = await import('./pdf-generator.js');
        return generatePDF(data);
    } else if (format === 'csv') {
        const { generateCSV } = await import('./csv-generator.js');
        return generateCSV(data);
    }
}

// Lazy-load on user interaction
button.addEventListener('click', async () => {
    const module = await import('./heavy-feature.js');
    module.default.init(); // default export
});

// Dynamic import with variable path (be careful — bundlers
// can't statically analyze these for tree-shaking)
const locale = navigator.language.split('-')[0];
const { messages } = await import(`./locales/${locale}.js`);

// Promise.all for parallel loading
const [{ Chart }, { DataTable }] = await Promise.all([
    import('./Chart.js'),
    import('./DataTable.js'),
]);

Code Splitting with Dynamic Imports

Bundlers like Vite and webpack treat every dynamic import() call as a split point. The dynamically imported module gets its own chunk file and is only downloaded when that import() call executes at runtime. This is how React's React.lazy() works under the hood:

React.lazy — Built on Dynamic Import
import React, { Suspense, lazy } from 'react';

// Each of these becomes a separate bundle chunk
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
const Analytics = lazy(() => import('./Analytics'));

function App() {
    return (
        <Suspense fallback={<div>Loading...</div>}>
            <Router>
                <Route path="/dashboard" component={Dashboard} />
                <Route path="/settings" component={Settings} />
                <Route path="/analytics" component={Analytics} />
            </Router>
        </Suspense>
    );
}
// Result: users only download the code for the page they visit

Advanced Module Patterns

Barrel Exports (Index Files)

A barrel file re-exports from multiple modules through a single entry point. This is common in React component libraries and feature folders — instead of importing from deep paths, consumers import from the directory.

components/index.js — Barrel File
// components/Button/Button.jsx
export default function Button({ children, onClick }) { /* ... */ }

// components/Modal/Modal.jsx
export default function Modal({ isOpen, children }) { /* ... */ }

// components/Input/Input.jsx
export { Input, TextArea, Select };

// components/index.js — barrel file
export { default as Button } from './Button/Button';
export { default as Modal } from './Modal/Modal';
export { Input, TextArea, Select } from './Input/Input';

// Now consumers import from one place:
import { Button, Modal, Input } from './components';
// Instead of:
import Button from './components/Button/Button';
import Modal from './components/Modal/Modal';
Barrel File Performance Warning

Barrel files can hurt build performance in large codebases because bundlers must parse the entire barrel to determine what's used. If you import just Button, the bundler still processes all re-exports. For large component libraries, consider importing directly from the source file. Vite and webpack's tree-shaking mitigate this, but it's worth measuring.

Re-exports and Module Aggregation

Re-export Syntax
// Re-export everything from another module
export * from './math.js';

// Re-export with rename (avoids naming conflicts)
export { add as mathAdd } from './math.js';

// Re-export a default as named
export { default as Calculator } from './Calculator.js';

// Re-export a named as default
export { add as default } from './math.js';

// Re-export everything EXCEPT default
export * from './utils.js'; // named only — doesn't include default

Circular Dependencies

ES modules handle circular dependencies (A imports B which imports A) through live bindings: the import is a reference, not a copy. By the time the circular reference is actually called (not just declared), both modules are fully initialized. However, circular dependencies are still a code smell — they make the dependency graph harder to reason about.

Understanding Live Bindings

Unlike CommonJS where require() returns a snapshot copy of the exports, ES module imports are live read-only bindings. If the exporting module changes the exported value, all importers see the updated value immediately:

// counter.js
export let count = 0;
export function increment() { count++; }

// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 ← live binding reflects the change

// With CommonJS this would NOT work — you'd get a stale copy:
// const { count } = require('./counter'); // snapshot
// increment();
// console.log(count); // still 0 in CommonJS!

Setting Up ES Modules in Node.js

Node.js supports ES modules natively since v12 (stable in v14). To enable them, you have two options:

1

Set "type": "module" in package.json

All .js files in the package are treated as ES modules. Use .cjs extension for any CommonJS files.

package.json
{
    "name": "my-app",
    "version": "1.0.0",
    "type": "module",
    "scripts": {
        "start": "node src/index.js"
    }
}
2

Use the .mjs file extension

Name your file app.mjs instead of app.js. Node.js treats .mjs files as ES modules regardless of the package.json setting.

3

Always include file extensions in import paths

Unlike bundlers, Node.js ESM does not add .js automatically. Write import { fn } from './utils.js', not './utils'.

Node.js ES Module Example
// src/utils.js
export function formatCurrency(amount, currency = 'USD') {
    return new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency,
    }).format(amount);
}

export const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

// src/index.js — must use .js extension
import { formatCurrency, sleep } from './utils.js';

// __dirname and __filename are not available in ESM
// Use import.meta.url instead:
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

console.log(formatCurrency(1234.56)); // $1,234.56
await sleep(100);
console.log('Done', __dirname);

Key Takeaways

JavaScript modules transform how you organize and share code. Here's a quick reference for everything covered:

Module System Cheatsheet

  • Named exports: Use for utility modules with multiple exports. Import with { name } syntax.
  • Default export: One per module. Best for the module's primary purpose (a class, component, or main function). Import without braces.
  • Namespace import: import * as Namespace from './mod.js' — useful for grouping related utilities.
  • Dynamic import: await import('./mod.js') — loads modules on demand, enables code splitting and conditional loading.
  • Re-exports: export { x } from './other.js' — build barrel files for clean public APIs.
  • Live bindings: ES module imports reflect real-time changes to exported values, unlike CommonJS snapshots.
  • Node.js ESM: Requires "type": "module" in package.json or .mjs extension. Always include file extensions in paths.
  • Prefer named exports in most cases for better IDE support, refactoring safety, and tree-shaking.
"Modules are not just a code organization tool — they are a contract between parts of your system. Good module boundaries define the architecture of your application."

Understanding ES modules deeply — knowing when to use named vs default exports, when to reach for dynamic imports, and how live bindings differ from CommonJS — puts you in control of your application's structure and performance. Whether you're building a React app with Vite, a Node.js API, or a vanilla JS project, modules are the foundation everything else is built on.

JavaScript Modules ES6 import export Dynamic Import Code Splitting
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.