Es Modules And App Structure
ES modules give every JavaScript file its own scope. Default exports vs named exports, the window.app pattern, and how to chain modules without creating circular dependencies.
I used to think modules were mostly a bundling concern - something Webpack handled so I did not have to think about it. Working with native ES modules in the browser changed that. The import/export system is solving a concrete problem: two files declaring a variable named menu will fight over window.menu unless each file has its own scope. Modules give them that.
The Essentials
- type="module": Adding this to a
<script>tag converts it to an ES module. Variables in the file are scoped to that file, not global. - Default exports: One thing exported as the primary export of a file. Imported without curly braces.
- Named exports: Multiple things exported from the same file. Imported with curly braces.
.jsextension required: When importing in the browser (without a bundler), you must include the file extension in the import path.- window.app: A single global namespace object for state that genuinely needs to be shared across modules.
Without Modules, Everything Is Global
A classic script tag puts all its top-level variables in window. Two files that both declare a variable named menu will conflict. The second one overwrites the first.
This was the reality of web development before ES modules. The community workaround was to bundle all files into one using tools like webpack or Rollup. The bundle had one scope, and the tooling managed the naming.
With modern browsers, bundling for module isolation is optional. You can use ES modules natively.
type="module" Changes the Rules
<script type="module" src="app.js"></script>Now app.js is an ES module. Variables declared at the top level of app.js stay in app.js. The browser treats the file as a unit with its own scope.
This script tag also implies defer behavior automatically. The file is fetched in parallel with HTML parsing and executes after parsing completes.
Default Exports vs Named Exports
Default export: The primary thing a module is meant to provide. One per file.
// services/Store.js
const Store = { menu: null, cart: [] };
export default Store;
// Importing it
import Store from './services/Store.js';No curly braces when importing a default export. You can name it anything on the import side.
Named exports: Multiple things from one file. Each has an explicit name.
// services/menu.js
export async function loadData() { /* ... */ }
export function clearMenu() { /* ... */ }
// Importing them
import { loadData, clearMenu } from './services/menu.js';Curly braces are required. The names must match what was exported.
The rule of thumb: if a module has one primary thing to offer, use a default export. If it offers utilities or multiple functions, use named exports.
The .js Extension in Browser Imports
When a bundler resolves imports, it can find files without extensions using conventions from the build config. The browser has no such conventions. It resolves module paths as URLs. './services/Store' is not a valid URL. './services/Store.js' is.
// Will fail in the browser without a bundler
import Store from './services/Store';
// Works in the browser
import Store from './services/Store.js';This is the most common source of confusion when moving between bundled frameworks and native ES modules.
The Module Chain
A typical vanilla JS app has a chain of module dependencies:
app.js
imports from services/menu.js
imports from services/API.js
imports from services/Store.jsEach module imports only what it needs. API.js has no knowledge of Store.js. Store.js has no knowledge of API.js. menu.js coordinates between them. app.js starts everything up.
This separation means each module can be changed without touching the others, as long as the exported interface stays the same.
The window.app Pattern
Modules isolate their scope, which is good. But sometimes state genuinely needs to be accessible from multiple disconnected parts of the app. One approach is to pass the shared state as arguments wherever it is needed. Another is to create a deliberate global namespace:
// In app.js
import Store from './services/Store.js';
import Router from './services/Router.js';
window.app = { store: Store, router: Router };Now window.app.store is accessible anywhere. The key distinction from old-style globals: this is intentional. There is one global object with a clear name, not many unnamed variables scattered across files. If the browser ever defines an app property on window, you can prefix yours to avoid a collision.
Further Reading and Watching
- MDN: JavaScript modules - Comprehensive guide to ES module syntax, static vs dynamic imports, and module loading behavior.
- MDN: import - Full reference for all import forms including namespace imports and dynamic imports.
Video:
- JavaScript Modules (ES6) by Traversy Media. Practical walkthrough of export, import, default, and named exports with real examples.