Top-level variables are private
In a classic script, a top-level var or function becomes global. In a module, top-level declarations belong to the module and do not leak.
let count = 0; // private to this module
export function increment() {
count++;
return count;
}
export function getCount() {
return count;
}
Other files cannot touch count directly. They can only use the exported functions. That is encapsulation, and it is the reason modules replaced tricks like the IIFE.
A module runs once
The first time any file imports a module, the browser runs its top-level code. Later imports reuse the same instance.
<script type="module">
const source = "data:text/javascript," + encodeURIComponent(`
let count = 0;
export const increment = () => ++count;
`);
const first = await import(source);
const second = await import(source); // same module instance
first.increment();
console.log(second.increment()); // → 2
console.log(first === second); // → true
</script>
Both imports share one count. This makes a module a simple singleton: a good place for a shared configuration object, a cache or a small store.
Strict mode
Modules always run in strict mode. Mistakes such as using an undeclared variable throw an error instead of silently creating a global.
<script type="module">
try {
undeclared = 5;
} catch (error) {
console.log(error.name); // → ReferenceError
}
</script>
this at the top level
In a module, this at the top level is undefined (not window).
Top-level await
Modules can use await at the top level, outside any function.
const response = await fetch("/config.json");
export const config = await response.json();
Any module that imports config.js waits until it has loaded. Use it carefully, because it delays everything that depends on it.
Sharing state: the rules
- Exported values are live bindings (see Named Exports), but importers cannot assign to them.
- Exported objects can be changed by anyone who imports them, because they share the same object. Prefer exporting functions that change the state.
const state = { user: null };
export const getUser = () => state.user;
export const setUser = user => { state.user = user; };
Circular imports
If a.js imports b.js and b.js imports a.js, it is a cycle. ES modules handle it, but you may read a value before it is initialised, giving confusing errors. Avoid cycles by moving shared code into a third module.
Globals still exist
Modules can use globals like window, document and console. Scope only affects your declarations.