Skip to main content

Command Palette

Search for a command to run...

JavaScript Modules: Import and Export Explained

Updated
•7 min read•View as Markdown

Remember that one JavaScript file you inherited that was 2,000 lines long? The one where changing a variable in one function mysteriously broke something completely unrelated on the other side of the file?

Yeah, me too.

This is the problem modules were designed to solve. Before ES6 gave us a proper module system, JavaScript developers had to get creative with <script> tags, global variables, and naming conventions just to share code between files. It was chaos.

Let's fix that.


The Problem: When Everything Lives Together

Imagine building a small app. Initially, you throw everything in one file:

// app.js
let users = [];
let activeUser = null;

function addUser(name, email) { /* ... */ }
function removeUser(id) { /* ... */ }

function renderDashboard() { /* ... */ }
function updateChart() { /* ... */ }

function saveToStorage() { /* ... */ }
function loadFromStorage() { /* ... */ }

This works fine when your app is small. But six months later, you've got 50 functions, three developers touching the same file, and no clear separation between what's public and what's internal.

Want to reuse addUser in a different project? Good luck extracting it without dragging along half the file's dependencies.

How Modules Fix This

Modules let you split code into separate files where each file is its own isolated scope. You explicitly choose what to expose and what to keep private.

Here's the basic idea:

// users.js - This file is a module

// This stays private to the file
let users = [];

// This gets exported so other files can use it
export function addUser(name, email) {
  users.push({ id: Date.now(), name, email });
}

export function removeUser(id) {
  users = users.filter(u => u.id !== id);
}
// app.js - Another module

// Bring in the functions we want
import { addUser, removeUser } from './users.js';

addUser('Sarah', 'sarah@example.com');
removeUser(1);

The export keyword marks something as available to other files. The import keyword pulls it in. That's the core idea.


Exporting: Two Ways to Share

There are two flavors of exports in JavaScript: named exports and default exports.

Named Exports

Named exports are exactly what they sound like—you export something by its name.

// math.js
export const PI = 3.14159;

export function double(n) {
  return n * 2;
}

export function halve(n) {
  return n / 2;
}

You can import each one individually:

import { PI, double, halve } from './math.js';

Or rename them on import if the names clash with something you already have:

import { double as twice, halve as half } from './math.js';

Default Exports

A file can have one default export. This is useful when a module's main purpose is to export a single thing—a class, a function, or an object.

// config.js
const settings = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retries: 3
};

export default settings;

When importing a default export, you get to pick the name:

import mySettings from './config.js';
// mySettings is the settings object

You can also mix default and named exports in the same file:

// logger.js
export default function log(message) {
  console.log(`[LOG] ${message}`);
}

export function logError(err) {
  console.error(`[ERROR] ${err}`);
}
import logger, { logError } from './logger.js';

logger('Server started');
logError('Connection failed');

Importing:Flexibility in How You Bring Things In

JavaScript gives you a few patterns for importing, and you can mix and match based on what you need.

Import everything as an object:

import * as users from './users.js';

users.addUser('Sarah', 'sarah@example.com');
users.removeUser(1);

This is handy when you're not sure what a module exports, or when you want to namespace things clearly.

Import just what you need:

import { addUser } from './users.js';

Tree-shaking tools (like webpack or Rollup) can often detect unused imports and remove them from your final bundle, keeping file sizes down.

Dynamic imports:

Sometimes you don't want to load a module upfront. Maybe it's heavy and rarely needed:

async function loadAnalytics() {
  const analytics = await import('./analytics.js');
  analytics.trackPageView();
}

Regular import statements are static—they run at the top of the file and can't be conditional. Dynamic import() returns a promise, so you can load modules on demand.

The Benefits: Why Bother?

By now you might be thinking "sure, but I could just use separate <script> tags and be done with it." Here's why modules are worth the learning curve:

  1. No more global pollution. Each module has its own scope. Variables you declare inside a module don't magically appear on window. This eliminates an entire class of bugs where two scripts accidentally use the same variable name.

  2. Explicit dependencies. When you import from ./users.js, it's crystal clear where that function comes from. No more hunting through <script> tags to figure out load order.

  3. Easier testing. Want to test just your addUser function? Import it directly. You can swap in a mock version without touching your production code.

  4. Reusability. Modules are self-contained. addUser comes with its dependencies cleanly packaged. You can drop it into a different project without worrying about what else it needs.

  5. Team collaboration. Different developers can work on different modules without stepping on each other. The public API (your exports) becomes a contract that everyone agrees on.

Common Gotchas

A few things trip people up when they're getting started:

You need a server. Modules work via the file:// protocol in modern browsers, but you'll hit CORS errors in older environments. Use a local dev server (npx serve or vite) to avoid head-scratching errors.

Extension matters. In Node.js, you might write from './users'. In browsers, include the extension: from './users.js'. Know your environment.

import and export must be top-level. Unlike require() in CommonJS, you can't use them inside if statements or functions. They're static, which is actually a good thing—it makes code analysis easier for tools.

Default doesn't mean required. Importing a default export is optional. You can import named exports without importing the default, and vice versa.


Putting It Together

Here's how a small project might look with modules:

src/
├── main.js         → Entry point, imports and wires things together
├── users.js        → User management functions
├── posts.js        → Post creation and retrieval
├── api.js          → API client, makes HTTP calls
└── utils.js        → Helper functions (formatDate, generateId, etc.)
// main.js
import { createUser, getUser } from './users.js';
import { createPost, getFeed } from './posts.js';
import { fetchWithRetry } from './api.js';

async function initialize() {
  const user = await createUser('Alex', 'alex@example.com');
  const post = await createPost(user.id, 'Hello, modules!');
  const feed = await getFeed();
  
  console.log(feed);
}

initialize();

Each file has a clear job. Each import is intentional. When something breaks, you know exactly where to look.


Conclusion

Modules aren't just a JavaScript feature—they're how you build maintainable software. The small upfront investment in understanding export and import pays off every time you open a codebase six months later and can actually understand it.

The core takeaways:

  • export marks code as available to other files

  • import brings in what you need from other files

  • Named exports use names directly; default exports export one main thing

  • Modules give you scope isolation, explicit dependencies, and better organization

Start with two files. Export a function from one and import it in the other. Once that clicks, you'll wonder how you ever lived without them.