RizTech Academy logo
RizTech Academy
Modules and ToolingLesson 1 of 325 min

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

  1. Split a two-function file into a module and an importer. Run it over npx serve.
  2. Open the same page by double-clicking the file and read the error. Then go back to the server.
  3. Export one thing as default and two as named, and import all three.
  4. Import a name that the module does not export and see what you get.
  5. Leave the .js off an import in the browser and read the error.
  6. Put a console.log in a module body, import it from three files, and confirm it runs once. Check the Network panel for the request count.
  7. Build the store.js singleton and change its state from one module, then read it from another.
  8. Prove the URL identity rule: import the same file twice, once with ?v=2, and confirm they do not share state.
  9. Use a function above its import line and confirm hoisting.
  10. Load a module with dynamic import() behind a button click, and watch the request happen only when clicked.
  11. Make a circular import between two files and find the undefined. Then fix it by moving the shared thing into a third module.
  12. 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