ES Modules Logo ESModules.com

Common Patterns

Best practices and design patterns for ES Modules.

Singleton Pattern

ES Modules are singletons by default - they're only executed once:

// database.js
const connection = await connectToDatabase();
export { connection };

// Multiple imports get the same instance
import { connection } from './database.js'; // Same connection

Feature Flags

// features.js
export const ENABLE_DARK_MODE = true;
export const ENABLE_ANALYTICS = false;

// app.js
import { ENABLE_ANALYTICS } from './features.js';
if (ENABLE_ANALYTICS) {
  await import('./analytics.js');
}

Plugin System

// plugin-manager.js
const plugins = [];
export function registerPlugin(plugin) {
  plugins.push(plugin);
}
export function getPlugins() {
  return plugins;
}

// plugins/logger.js
import { registerPlugin } from '../plugin-manager.js';
registerPlugin({ name: 'logger', init() { /* ... */ } });

Code Splitting by Route

// router.js
const routes = {
  '/': () => import('./pages/home.js'),
  '/about': () => import('./pages/about.js'),
  '/contact': () => import('./pages/contact.js')
};

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

Re-exports Pattern

Create a barrel file to simplify imports:

// utils/index.js
export { add, subtract } from './math.js';
export { formatDate } from './date.js';
export { validateEmail } from './validation.js';

// Now consumers can import everything from one place
import { add, formatDate } from './utils/index.js';

Performance Warning:

Barrel files can hurt tree-shaking in some bundlers. If a barrel re-exports a large module, importing any single export may pull in the entire barrel. Consider direct imports for performance-critical code paths.

Try It Live

Dependency Injection

Use module factories to inject dependencies, making code testable and configurable:

// logger.js - injectable module factory
export function createLogger(transport = console) {
  return {
    info: (msg) => transport.log(`[INFO] ${msg}`),
    error: (msg) => transport.error(`[ERROR] ${msg}`)
  };
}

// app.js - production usage
import { createLogger } from './logger.js';
const logger = createLogger();

// test.js - inject mock transport
import { createLogger } from './logger.js';
const mockTransport = { log: vi.fn(), error: vi.fn() };
const logger = createLogger(mockTransport);

Environment-Based Loading

Load different module implementations based on the runtime environment:

// Using dynamic imports for environment branching
const db = await import(
  process.env.NODE_ENV === 'test'
    ? './db-mock.js'
    : './db-real.js'
);

// Using package.json conditional exports
// package.json
{
  "exports": {
    ".": {
      "node": "./src/platform-node.js",
      "browser": "./src/platform-browser.js",
      "default": "./src/platform-fallback.js"
    }
  }
}

Continue Learning