AniUI Academy

Modules

Named and default exports, live bindings, dynamic import for code splitting, and circular imports — plus how ESM differs from the require calls still everywhere.

12 min read

Before modules, every script shared one global namespace. Two libraries both defining $, a variable named config overwritten by a file loaded later, and a page that broke when you reordered your script tags. Modules exist so a file can decide what it shares and nothing else escapes.

Named exports

Mark anything you want to share with export, and pick it up by name.

// lib/pricing.js
export const TAX_RATE = 0.18;
 
export function withTax(amount) {
  return amount * (1 + TAX_RATE);
}
 
function formatPaise(amount) {
  return (amount / 100).toFixed(2);
}
 
export { formatPaise };
// checkout.js
import { withTax, TAX_RATE } from "./lib/pricing.js";

The names must match what was exported. formatPaise shows the other form: declare first, export at the bottom in one list. Both are fine, though inline export keeps the two facts in one place.

Rename on either side with as, which you need when two modules export the same name:

import { withTax as addGst } from "./lib/pricing.js";
import { format as formatDate } from "./lib/date.js";

Or bring the whole module in as one object:

import * as pricing from "./lib/pricing.js";
 
pricing.withTax(1900);

Namespace imports read well for a module that is genuinely a toolkit. For everything else, importing three names is clearer than importing a bag.

Default exports

A module can nominate one main thing.

// lib/logger.js
export default function createLogger(prefix) {
  return (message) => console.log(prefix, message);
}
import createLogger from "./lib/logger.js"; // no braces
import makeLogger from "./lib/logger.js";   // also legal, and the problem

The importer chooses the name, so the same function ends up called three different things across a codebase and none of them are searchable. Prefer named exports. Use a default only where a framework asks for one — a Next.js page component, for example.

You can mix both, and re-export to build a public surface for a folder:

// lib/index.js
export { withTax, TAX_RATE } from "./pricing.js";
export { default as createLogger } from "./logger.js";

Module scope is not global

Everything declared in a module file is private to that file unless exported. Two modules can both declare const config and never collide. There is no implicit sharing, and top-level this is undefined rather than the global object.

Modules are also strict mode by default, and each module runs exactly once no matter how many files import it. That last point makes a module the simplest possible singleton: a const cache = new Map() at the top of a module is shared by every importer.

It also means top-level code in a module is a side effect that runs on first import. An import with no bindings exists for exactly that:

import "./styles.css";
import "./polyfills.js";

Keep those rare. A module that does work merely by being imported is hard to test and hard to reason about when the import order changes.

Imports are hoisted and static

Import statements are processed before any of your code runs. The engine reads the whole dependency graph first, then executes. Two consequences.

The specifier must be a plain string literal. You cannot compute it, and you cannot put an import inside an if:

import { chart } from "./charts/" + name; // SyntaxError

And an import is hoisted to the top of the file regardless of where you wrote it, so this works even though it looks wrong:

setup();
import { setup } from "./setup.js";

That is legal, not advisable. Keep imports at the top where readers expect them.

Live bindings

An import is a window onto the exporter's variable, not a copy of its value.

// counter.js
export let count = 0;
export function increment() {
  count += 1;
}
// main.js
import { count, increment } from "./counter.js";
 
console.log(count); // 0
increment();
console.log(count); // 1 — the binding is live

You cannot assign to count from main.js; imported bindings are read-only there. Exporting mutable state like this is rarely a good idea, but knowing the rule explains a lot of otherwise baffling behaviour.

Try it yourself

The playground has no module system, so this uses an IIFE as a stand-in for module scope, and a promise-returning function in place of await import().

Try it yourself
Loading playground...

Dynamic import

When you do need a computed path or a module loaded on demand, import() is a function that returns a promise for the namespace object.

async function showChart(points) {
  const { renderChart } = await import("./lib/chart.js");
  renderChart(points);
}

The real payoff is code splitting. A charting library that only runs on one tab does not belong in the bundle every visitor downloads. Bundlers see the import() call and emit a separate chunk fetched at that moment. Load it on the click, on the route change, or when the element scrolls into view.

Remember it resolves to the namespace, so a default export arrives as .default:

const { default: createEditor } = await import("./lib/editor.js");

Circular imports

Two modules that import each other will load, but one of them starts running before the other has finished. Function declarations are hoisted so they usually survive; a const read at the top level will be in its temporal dead zone and throw, or arrive as undefined.

Do not learn to work around this. A cycle is a design signal. Move the shared piece into a third module both can import, or move the function that reaches back across the cycle into the module that needs it.

ESM and CommonJS

You will meet both. CommonJS is Node's older system:

const { withTax } = require("./pricing");
module.exports = { withTax };

require is synchronous and runs at the moment that line executes, so it can take a computed path and sit inside an if. ESM is static and asynchronous, which is what makes tree shaking and top-level await possible.

In Node, a .mjs file is ESM and a .cjs file is CommonJS. A plain .js file follows the nearest package.json: "type": "module" makes it ESM, and its absence means CommonJS. ESM in Node also wants the file extension in relative specifiers. Most of the confusing errors in this area — Cannot use import statement outside a module, or require is not defined — are that setting disagreeing with your code.

What to remember

  • A module shares only what it exports; everything else stays private to the file.
  • Prefer named exports, keep one concern per file, and reserve default exports for frameworks that require them.
  • Static imports are hoisted and their paths must be literal strings; imports are live bindings.
  • Use import() for on-demand loading and code splitting, and treat a circular import as a design problem.

Check yourself

4 questions · pass 3/4 to unlock Promises in Depth

up to 50
  1. 1.A module exports let count = 0 and an increment() function. Another module imports both, calls increment(), then logs count. What appears?

  2. 2.What happens if the module specifier is a variable, as in import { chart } from path?

  3. 3.Why do most teams prefer named exports over a default export?

  4. 4.What does await import("./chart.js") give you?

4 left to answer