ES modules split code into files that export what they share and import what they need. Each module has its own scope, so variables don't leak into the global page. Load the entry file with <script type="module" src="main.js"> — modules are deferred automatically.
Module imports in the browser need full relative paths, including .js. Use import() (dynamic import) to load rarely used code only when it's needed, making the first page load faster.
For larger projects, use npm and a build tool such as Vite: it serves your code instantly during development, lets you import npm packages, and bundles and minifies everything for production with npm run build.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Modules</title>
<script type="module" src="main.js"></script>
</head>
<body>
<p id="summary"></p>
<button id="report">Show detailed report</button>
<pre id="output"></pre>
</body>
</html>export const PASS_MARK = 30;
export function average(values) {
return values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0;
}
export default function gradeFor(score) {
if (score >= 75) return "A";
if (score >= 65) return "B";
if (score >= 45) return "C";
if (score >= PASS_MARK) return "D";
return "F";
}import gradeFor from "./grading.js";
export function buildReport(scores) {
return scores.map((s, i) => `Student ${i + 1}: ${s} (${gradeFor(s)})`).join("\n");
}import gradeFor, { average, PASS_MARK } from "./lib/grading.js";
const scores = [88, 42, 71, 25];
const avg = average(scores);
document.querySelector("#summary").textContent =
`Average ${avg.toFixed(1)} (${gradeFor(avg)}), ${scores.filter((s) => s >= PASS_MARK).length} passed`;
document.querySelector("#report").addEventListener("click", async () => {
const { buildReport } = await import("./lib/report.js"); // loaded only on first click
document.querySelector("#output").textContent = buildReport(scores);
});npm create vite@latest results-app -- --template vanilla
cd results-app
npm install
npm run dev # development server with instant reload
npm run build # optimised files in dist/ ready to deployKey points
- Modules = one file, one responsibility, explicit
import/export. - In the browser, use
type="module"and include.jsin import paths. - Dynamic
import()loads code on demand; Vite handles npm packages and production builds.
Exercise
Split the attendance counter from the Beginner track into modules: state.js (data and update functions), view.js (rendering) and main.js (event wiring). Then create a Vite project and move the code into it.
Show solution
Try the exercise yourself first — then compare your approach with this one.
Each module has one job: state.js owns the data and the rules for changing it, view.js turns state into DOM updates, and main.js connects button clicks to state changes and re-renders.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Attendance</title>
<style>.full { color: #b5541e; font-weight: bold; }</style>
<script type="module" src="main.js"></script>
</head>
<body>
<p>Present: <span id="count">0</span> / 5</p>
<button id="add">Mark present</button>
<button id="reset">Reset</button>
<ul id="log"></ul>
</body>
</html>export const LIMIT = 5;
export const state = { present: 0, arrivals: [] };
export function markPresent(time) {
if (state.present >= LIMIT) return;
state.present++;
state.arrivals.push(time);
}
export function reset() {
state.present = 0;
state.arrivals = [];
}import { LIMIT } from "./state.js";
const countEl = document.querySelector("#count");
const addBtn = document.querySelector("#add");
const log = document.querySelector("#log");
export function render(state) {
countEl.textContent = state.present;
countEl.classList.toggle("full", state.present === LIMIT);
addBtn.disabled = state.present === LIMIT;
log.replaceChildren(
...state.arrivals.map((time, i) => {
const li = document.createElement("li");
li.textContent = `Student ${i + 1} arrived at ${time}`;
return li;
}),
);
}import { markPresent, reset, state } from "./state.js";
import { render } from "./view.js";
document.querySelector("#add").addEventListener("click", () => {
markPresent(new Date().toLocaleTimeString());
render(state);
});
document.querySelector("#reset").addEventListener("click", () => {
reset();
render(state);
});
render(state);