Modules and browser storage
Projects and patterns15 min readLesson 1 of 4
1.1 Why split code into modules
A single script file works while the project is small. As features grow, the file collects unrelated helpers, name collisions become likely, and every change risks breaking a distant part. Modules give each file its own scope and an explicit list of what it exports.
An ES module is a JavaScript file that uses export and import. Browsers load module scripts with <script type="module" src="app.js"></script>; Node accepts the same syntax in .mjs files or a package configured for modules. The syntax is the same in both places.
A module's top-level names are private by default. Only exported names are visible to importers, which keeps the public surface small and makes refactoring safer.
1.2 Export and import
A named export exposes a declaration under its name. A default export exposes one main value. The two can be mixed, though a file usually has either several named exports or one default export plus helpers.
The importing file lists the names it needs. Named imports use braces and must match the exported names; default imports take any local name. A path that starts with ./ or ../ is relative to the importing file, and a bare path such as "utils" refers to a package or a configured alias.
A file can re-export from another module, which is useful for a folder's public entry point. Avoid circular imports, where two modules import each other: the values may be undefined at the moment one module evaluates, and the resulting bug is hard to trace.
The two files below show the shape of a module pair. They are not runnable as one snippet, which is why they appear in a single fenced block rather than a code block. In the project, the first file is math.js and the second imports it.
// math.js
export const add = (a, b) => a + b;
export default function multiply(a, b) {
return a * b;
}
// app.js
import multiply, { add } from "./math.js";
console.log(add(2, 3));
console.log(multiply(2, 3));
Both logs print 5 and 6 in a real module setup. The default export is imported without braces, and the named export needs braces. A mismatch in either place fails at load time with a clear module error, which is better than a silent undefined deep in the code.
Module scripts load asynchronously and run after the document parses. They also run in strict mode automatically and have their own top-level scope. A module's variables never become globals, which is the main reason large pages use modules.
1.3 The module pattern without a bundler
Before modules were widespread, scripts used an immediately invoked function expression to create a private scope and return a public object. The pattern is still useful for small demos and for understanding closures: the returned functions keep access to the private variables.
The IIFE runs once, creates count and the two functions, and returns an object with only the functions the caller should use. The counter cannot be changed from outside except through increment, so the public surface enforces the rule.
const counterModule = (() => {
let count = 0;
function increment() {
count += 1;
}
function value() {
return count;
}
return { increment, value };
})();
counterModule.increment();
counterModule.increment();
console.log(counterModule.value());
console.log(Object.keys(counterModule).join(","));Output
2 increment,value
1.4 Browser storage
localStorage stores string key-value pairs in the browser and keeps them after the tab closes. sessionStorage uses the same API but clears when the tab closes. Cookies are for server-readable state and travel with requests; use storage for client-side preferences and small app data.
The API is small: setItem(key, value), getItem(key), removeItem(key) and clear(). getItem returns null for a missing key, and every value is stored as a string. A number written directly comes back as text, so convert it deliberately.
Storage is synchronous and limited to a few megabytes per origin. A large list should not be stored on every keystroke, and a huge JSON string can exceed the quota and throw. Catch the error and tell the user, or trim the data before saving.
Storage is readable by any script on the same origin and is not encrypted. Never put tokens, passwords or private learner data there. Treat it as a convenience cache for non-sensitive state that can be rebuilt.
localStorage.setItem("theme", "dark");
console.log(localStorage.getItem("theme"));
localStorage.setItem("settings", JSON.stringify({ fontSize: 18 }));
const settings = JSON.parse(localStorage.getItem("settings"));
console.log(settings.fontSize);
localStorage.removeItem("theme");
console.log(localStorage.getItem("theme"));Output
dark 18 null
1.5 Storing objects
Storage holds strings, so an object goes through JSON.stringify on the way in and JSON.parse on the way out. Wrap the parse in try catch: old data may be corrupt, or a previous version may have written a different shape.
A versioned key such as "tasks-v1" makes a schema change explicit. When the shape changes, read the old key, migrate the data and write the new key, or clear the old value on purpose. A missing migration is a common source of undefined fields after a deploy.
Read storage once at start-up, keep the live data in memory, and write when it changes. Reading storage inside every render is slower and can produce inconsistent state when several tabs are open.
1.6 Loading modules in the page
The script tag that loads a module needs type="module". Module scripts are deferred by default, so the document is parsed before they run, and each module keeps its own scope. The async attribute makes a module run as soon as it is fetched, which is rarely what an app entry point wants.
The import graph is resolved before the entry module runs. A syntax error in any imported file blocks the whole graph, so a blank page with one module error is a known failure mode. Read the first module error in the console and fix the path or the export name first.
Dynamic import returns a promise for a module and loads it on demand. It is useful for a heavy feature that is not needed at start-up. Keep static imports for the main path and reach for dynamic import only when the split earns its complexity.
1.7 Storage across tabs
Every tab on the same origin shares localStorage. A change in one tab is not visible to the in-memory state of another tab until it re-reads storage. The storage event fires in the other tabs when a value changes, which is the hook for keeping two open views roughly in sync.
Handling that event is optional for a single-view app. For a shared list, listen for storage, compare the key, and reload the array before rendering. The event does not fire in the tab that made the change, so the writing tab already has the new state.
Two tabs writing at the same time can still overwrite each other because the last write wins. Storage is not a coordination service. For a real multi-user app, the server owns the data and storage holds only a cache.
1.8 Common mistakes
- Forgetting that localStorage stores strings, so numbers come back as text.
- Calling JSON.parse on missing or corrupt data without a try catch.
- Storing sensitive data such as tokens or private user details.
- Writing large data on every keystroke and hitting the storage quota.
- Using circular imports and reading an undefined binding at load time.
- Importing a named export without braces, or a default export with braces.
Exam
Module and storage questions test boundaries. For modules, check the export form, the path and the order in which files load. For storage, follow the string round trip: set, get, parse, and handle the null case.
- Split a small file into two modules and import one helper.
- Save a string and a number to localStorage and read both back.
- Store an object with JSON and parse it with a try catch.
- Explain what sessionStorage clears and when.
