ES modules: import and export
The orders page from module 8 is one file holding error classes, a fetch wrapper, rendering and event handlers. It works, and it is the last point at which it works — the next feature makes it unreadable.
Modules split it up.
Export and import
// orders-api.js
export const RATE = 80;
export function total(plates) {
return plates * RATE;
}
// app.js
import { RATE, total } from './orders-api.js';
console.log(RATE);
console.log(total(3));
80
240
The names must match, and the braces are not an object — they are a list of names to bring in. Rename on the way if you need to:
import { RATE as PLATE_RATE } from './orders-api.js';
A default export is the one main thing a file provides:
export default function describe(plates) {
return `${plates} plates`;
}
import describe from './orders-api.js';
No braces, and the name is yours to choose — which is a real cost, because two files can import the same default under different names. Prefer named exports. They are consistent, they rename explicitly when they must, and your editor can auto-import them reliably.
| Form | Export | Import |
|---|---|---|
| Named | export const x = 1 |
import { x } from './f.js' |
| Named, renamed | import { x as y } from './f.js' |
|
| Default | export default fn |
import fn from './f.js' |
| Everything | import * as api from './f.js' |
|
| Side effect only | import './setup.js' |
You can also gather exports at the bottom, which some people prefer as a summary of the file's public surface:
export { RATE, total, describe };
Turning it on
In a browser, one attribute:
<script type="module" src="app.js"></script>
That changes four things, all improvements:
Imports work. Without type="module", import is a syntax error.
It is deferred automatically — module 1's defer, now free. The DOM is
parsed before your code runs.
It is always strict mode. No accidental globals, and this at the top level
is undefined rather than window.
Each module has its own scope. A const at the top of a module is private to
it:
console.log(window.RATE);
undefined
A classic <script> with var classicGlobal = 'leaked' does put it on
window. Modules do not, and that alone removes a whole category of
name-collision bugs.
One thing you must do: include the .js extension in the path. Node and
bundlers may let you omit it; browsers will not.
It needs a server
Opening a page that uses type="module" by double-clicking the file does not
work. Modules are fetched rather than simply read, and the browser refuses to
fetch them from a file:// address — the console reports a CORS failure, which
is confusing because there is no other origin involved.
Any static server fixes it:
npx serve
python3 -m http.server
Or the Live Server extension in VS Code. This is the "you will need a server" that module 1 promised, and it is why the to-do list in module 7 used a plain script.
Modules run once
This surprises people and is extremely useful.
// orders-api.js
console.log('module body ran');
export const RATE = 80;
Import that from three different files and the log appears once. The module is fetched once, evaluated once, and every importer gets the same instance — verified by watching the Network panel: many imports, one request.
So a module holding state is shared by everyone who imports it:
// store.js
let orders = [];
export function getOrders() {
return [...orders];
}
export function addOrder(order) {
orders = [...orders, order];
}
Every file importing this talks to the same orders. That is a module-scoped
singleton, and it is the simplest state management there is — the defensive copy
in getOrders is module 3's rule about handing back your private array.
One caveat: the identity is the resolved URL. import('./orders.js?v=2')
is a different module from ./orders.js, with its own separate state. Rarely
what you want, and a genuinely confusing bug when it happens.
Imports are hoisted
console.log(helper());
import { helper } from './helper.js';
helper ran
That works. Imports are resolved before any of the module body runs, so the
order of import lines does not matter. Put them at the top anyway — everybody
expects them there.
A consequence: you cannot conditionally import with a static import. There is
no if around it. For that you need the dynamic form.
Dynamic import
import() as a function returns a promise, and can go anywhere:
const module = await import('./charts.js');
module.render(data);
console.log(Object.keys(await import('./orders-api.js')).sort());
[ 'RATE', 'default', 'total' ]
Note default is a key of the namespace object.
This is how you load something only when it is needed — a chart library on a page where most users never open the chart. On a mid-range phone on mobile data, not downloading 200KB is a real improvement.
It rejects if the module cannot be loaded, so it can be wrapped in try/catch
like any other promise. A missing file gives a TypeError in Chrome.
Circular imports
a.js imports b.js, which imports a.js. It does not crash — but one of them
may see a partially-initialised version of the other, giving undefined for
something that is definitely exported.
Do not debug a circular import; remove it. Usually the shared thing belongs in a third module both import.
Node's two systems
Node predates ES modules and had its own, which you will still meet everywhere:
const fs = require('fs');
module.exports = { total };
That is CommonJS. Modern Node supports both:
| CommonJS | ES modules | |
|---|---|---|
| Import | require('./f') |
import { x } from './f.js' |
| Export | module.exports = |
export |
| File | .cjs, or .js by default |
.mjs, or .js with "type": "module" |
| Loading | Synchronous | Asynchronous |
require available |
Yes | No |
require('fs');
ReferenceError: require is not defined
That error inside an ES module means the two systems have been mixed.
Use ES modules for new code. Set "type": "module" in package.json — the
next lesson — and the same syntax works in the browser and in Node.
Splitting the orders page
For the module 8 page, a reasonable split:
app.js wiring: listeners, and calling the rest
orders-api.js fetchJson and the request functions
errors.js HttpError, NetworkError, TimeoutError
render.js building the DOM
format.js the Intl money formatter
The rule worth following: a module should have one reason to change. render
changes when the design does; orders-api changes when the server does. When
those live in one file, every change risks the other.
And keep the dependencies pointing one way — app imports render, render
does not import app. That is how you avoid circular imports by construction.
Check your work
Named exports use matching names in braces; a default export is imported without braces under any name. Prefer named.
type="module" gives you imports, automatic deferring, strict mode and a
private scope. A module's top-level const is not on window; a classic
script's var is.
Include the .js extension in browser imports.
A module page must be served over HTTP, not opened from the file system.
A module is evaluated once however many times it is imported — one network request, one shared instance. A module holding state is a singleton.
A different URL is a different module, so ./f.js?v=2 has separate state.
Imports are hoisted, so a function imported below can be used above. You
cannot conditionally import with static import.
import() is a function returning a promise, usable anywhere, and the
namespace includes default as a key.
require is not defined in an ES module — that ReferenceError means
CommonJS and ES modules have been mixed.
Practice
- Split a two-function file into a module and an importer. Run it over
npx serve. - Open the same page by double-clicking the file and read the error. Then go back to the server.
- Export one thing as default and two as named, and import all three.
- Import a name that the module does not export and see what you get.
- Leave the
.jsoff an import in the browser and read the error. - Put a
console.login a module body, import it from three files, and confirm it runs once. Check the Network panel for the request count. - Build the
store.jssingleton and change its state from one module, then read it from another. - Prove the URL identity rule: import the same file twice, once with
?v=2, and confirm they do not share state. - Use a function above its
importline and confirm hoisting. - Load a module with dynamic
import()behind a button click, and watch the request happen only when clicked. - Make a circular import between two files and find the
undefined. Then fix it by moving the shared thing into a third module. - Harder. Split your module 8 orders page into the five files above. It must behave identically afterwards. Then draw the import graph and check no arrow points backwards — if one does, that is the module that is doing two jobs.
Next: npm — using code other people have written, and what that costs.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship