Start with a small, explicit module

An ES module is a JavaScript file that can make selected values available to other files. Everything else stays within that module’s scope. This makes it easier to see which parts of a file are intended for reuse and which parts are implementation details. A useful starting point is to give each module one recognizable responsibility, such as formatting text, managing a counter, or updating a particular interface element.

For the examples below, assume a modern browser and a small project served from a local HTTP server. Put main.js, messages.js, and counter.js in the same directory. Load main.js from the page as a module by setting the script element’s type attribute to module and its src attribute to ./main.js. Serving the project over HTTP avoids file-loading restrictions that can affect modules opened directly from disk. The import paths in these examples are relative to the file containing each import.

Use named exports for distinct pieces of functionality

Named exports let a file expose several values under specific names. In messages.js, you could write: export const greeting = 'Hello'; export function makeMessage(name) { return `${greeting}, ${name}!`; } The file exports both a value and a function. A different file imports only what it needs: import { makeMessage } from './messages.js'; Then calling makeMessage('Ada') returns 'Hello, Ada!'.

The names inside the braces must match the exported names. To use a different local name, write import { makeMessage as formatGreeting } from './messages.js'; This changes the name in the importing file, not the export in messages.js. Named exports are often a good fit for utility modules because readers can identify the imported features at a glance. A module can have multiple named exports, but it does not need to export every helper it contains.

Use a default export for one primary value

A module may also have one default export. Suppose counter.js has one main job: creating a counter. It could contain: export default function createCounter(start = 0) { let count = start; return { increment() { count += 1; return count; } }; } The count variable remains private to each counter created by the function, while callers receive an increment method.

In main.js, import it without braces: import createCounter from './counter.js'; const counter = createCounter(); console.log(counter.increment()); The importer can choose a different local name for a default export, though keeping a descriptive name helps readers. Default and named exports can coexist in one file, but use that combination only when it makes the module clearer. A helpful convention is to choose named exports when a file offers several related tools and a default export when it has one obvious primary value. Neither style is required for all projects.

Connect the files and check the common pitfalls

The entry file can bring both modules together. In main.js, write: import { makeMessage } from './messages.js'; import createCounter from './counter.js'; const counter = createCounter(); console.log(makeMessage('Ada')); console.log(counter.increment()); With the page loading main.js as a module, the browser follows its imports and runs their code as needed. Open the browser console to see the greeting and the number 1. This example does not depend on a build tool or a package manager.

If an import fails, first check the path and filename, including the .js extension. In browser imports, './messages.js' identifies a relative file; 'messages.js' without the leading './' is not the same path form. Next, check the export style: import { createCounter } looks for a named export, but the counter example provides a default export. Finally, make sure the page loads the entry file as a module and that the local server is serving the expected files. Clear module boundaries make these errors easier to locate: each file declares what it provides, and each import states what it uses.

Back to category ↗