JavaScript Modules: import and export

JavaScript modules solve one of the language's most persistent problems: organizing code across multiple files without polluting the global namespace. Before ES6 modules arrived in 2015, developers...

Key Insights

  • Use named exports by default for better refactoring support and explicit imports—reserve default exports for primary module functionality like React components or class definitions
  • Modern JavaScript modules execute in strict mode automatically and create their own scope, eliminating global namespace pollution that plagued script-based architectures
  • Dynamic imports with import() enable code splitting and lazy loading, reducing initial bundle sizes by up to 60% in production applications

Introduction to JavaScript Modules

JavaScript modules solve one of the language’s most persistent problems: organizing code across multiple files without polluting the global namespace. Before ES6 modules arrived in 2015, developers relied on immediately-invoked function expressions (IIFEs), AMD, or CommonJS. These solutions worked but lacked native language support.

ES6 modules introduced a standardized syntax that works across browsers and Node.js (since version 12 with .mjs files or "type": "module" in package.json). Every major browser supports ES modules natively. Build tools like Webpack and Vite handle bundling for production, while development servers serve modules directly.

Modules provide three critical benefits: encapsulation (private implementation details), reusability (import the same code anywhere), and maintainability (clear dependencies between files). Once you understand the export and import mechanisms, you’ll structure applications more effectively.

Export Fundamentals

JavaScript offers two export types: named and default. Named exports allow multiple exports per module, while default exports provide a single primary export.

Named exports work inline or at the bottom of your file:

// math.js - inline named exports
export const PI = 3.14159;
export function square(x) {
  return x * x;
}
export function cube(x) {
  return x * x * x;
}

// OR export at bottom
const PI = 3.14159;
function square(x) {
  return x * x;
}
function cube(x) {
  return x * x * x;
}

export { PI, square, cube };

Default exports designate the primary export from a module:

// User.js - default export
export default class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }
  
  getProfile() {
    return `${this.name} (${this.email})`;
  }
}

You can combine both approaches:

// api.js - mixed exports
export const API_URL = 'https://api.example.com';
export const TIMEOUT = 5000;

export default class ApiClient {
  constructor(baseUrl = API_URL) {
    this.baseUrl = baseUrl;
  }
  
  async get(endpoint) {
    const response = await fetch(`${this.baseUrl}${endpoint}`);
    return response.json();
  }
}

When to use each: Use named exports for utility functions, constants, and when exporting multiple related items. Use default exports for classes, React components, or when a module has one clear primary purpose. Named exports provide better IDE support for refactoring since the import name must match the export name.

Import Fundamentals

Importing mirrors the export syntax. For named exports, use curly braces:

// Importing named exports
import { PI, square, cube } from './math.js';

console.log(square(5)); // 25
console.log(PI); // 3.14159

Default imports don’t use curly braces and let you choose any name:

// Importing default export
import User from './User.js';
import ApiClient from './api.js';

const user = new User('Alice', 'alice@example.com');

Import everything from a module using namespace imports:

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

console.log(MathUtils.PI); // 3.14159
console.log(MathUtils.square(4)); // 16

Rename imports to avoid naming conflicts:

// Aliasing imports
import { square as squareNum } from './math.js';
import { square as squareShape } from './geometry.js';

console.log(squareNum(5)); // 25

Mix default and named imports:

// Mixed imports
import ApiClient, { API_URL, TIMEOUT } from './api.js';

const client = new ApiClient(API_URL);

Advanced Module Patterns

Re-exporting creates clean public APIs by aggregating exports from multiple modules:

// services/index.js - re-exporting pattern
export { UserService } from './UserService.js';
export { AuthService } from './AuthService.js';
export { PaymentService } from './PaymentService.js';

// Now consumers import from one place
import { UserService, AuthService } from './services/index.js';

You can also transform exports while re-exporting:

// utils/index.js
export { formatDate as dateFormatter } from './date.js';
export * from './string.js'; // re-export all named exports

Dynamic imports load modules at runtime, enabling code splitting:

// Dynamic import for lazy loading
async function loadDashboard() {
  const { Dashboard } = await import('./components/Dashboard.js');
  const dashboard = new Dashboard();
  dashboard.render();
}

// Load only when user navigates to dashboard
document.getElementById('dashboard-link').addEventListener('click', loadDashboard);

Dynamic imports return promises and work great with route-based code splitting:

// router.js
const routes = {
  '/home': () => import('./pages/Home.js'),
  '/profile': () => import('./pages/Profile.js'),
  '/settings': () => import('./pages/Settings.js')
};

async function navigate(path) {
  const loadPage = routes[path];
  if (loadPage) {
    const module = await loadPage();
    module.default.render();
  }
}

Barrel exports (index.js files) simplify imports but use them judiciously—they can hurt tree-shaking:

// components/index.js
export { Button } from './Button.js';
export { Input } from './Input.js';
export { Modal } from './Modal.js';

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

Common Pitfalls and Best Practices

Circular dependencies occur when modules import each other. Avoid them through better architecture:

// BAD: Circular dependency
// userService.js
import { logAction } from './logger.js';
export function createUser() {
  logAction('user created');
}

// logger.js
import { createUser } from './userService.js'; // Circular!
export function logAction(action) {
  if (action === 'special') createUser();
}

// GOOD: Extract shared dependency
// events.js
export class EventEmitter {
  emit(event) { /* ... */ }
}

// userService.js
import { EventEmitter } from './events.js';
const events = new EventEmitter();

// logger.js
import { EventEmitter } from './events.js';
const events = new EventEmitter();

Default vs. named exports: Prefer named exports. They provide better refactoring support, clearer import statements, and prevent naming inconsistencies. Reserve default exports for React components and primary class exports.

File naming conventions matter. Match file names to primary exports:

// UserService.js (PascalCase for classes)
export default class UserService { }

// formatDate.js (camelCase for functions)
export function formatDate() { }

// constants.js (descriptive names for multiple exports)
export const API_URL = '...';
export const MAX_RETRIES = 3;

Practical Application

Here’s a realistic module structure for a task management application:

// utils/validation.js
export function isValidEmail(email) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

export function isValidDate(date) {
  return date instanceof Date && !isNaN(date);
}

// utils/index.js
export { isValidEmail, isValidDate } from './validation.js';
export { formatDate, parseDate } from './date.js';

// services/TaskService.js
import { isValidDate } from '../utils/index.js';

export class TaskService {
  constructor(apiClient) {
    this.apiClient = apiClient;
  }
  
  async createTask(title, dueDate) {
    if (!isValidDate(dueDate)) {
      throw new Error('Invalid due date');
    }
    return this.apiClient.post('/tasks', { title, dueDate });
  }
  
  async getTasks() {
    return this.apiClient.get('/tasks');
  }
}

// components/TaskList.js
export default class TaskList {
  constructor(taskService) {
    this.taskService = taskService;
  }
  
  async render(container) {
    const tasks = await this.taskService.getTasks();
    container.innerHTML = tasks.map(t => 
      `<div class="task">${t.title}</div>`
    ).join('');
  }
}

// main.js
import { TaskService } from './services/TaskService.js';
import TaskList from './components/TaskList.js';
import { apiClient } from './config/api.js';

const taskService = new TaskService(apiClient);
const taskList = new TaskList(taskService);

document.addEventListener('DOMContentLoaded', () => {
  taskList.render(document.getElementById('task-container'));
});

For browser usage, add type="module" to your script tag:

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

For Node.js, use .mjs extensions or add to package.json:

{
  "type": "module"
}

Modern bundlers like Vite and Webpack handle modules automatically. The key is organizing your code into focused modules with clear responsibilities. Keep utilities separate from business logic, services separate from UI components, and use barrel exports sparingly to maintain good tree-shaking.

JavaScript modules transformed how we write applications. Master exports and imports, avoid circular dependencies, and structure your projects with intentional module boundaries. Your code will be more maintainable, testable, and scalable.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.