Vanilla JavaScript - Rules and Conventions
1. Philosophy
- Vanilla first — No framework until the project proves it needs one. The platform covers interactivity, state, storage and animation; libraries enter only against documented gaps.
- Baseline-first — ES2024 stable, targeting ES2025. Baseline features are safe; "Newly available" requires a runtime-support check; anything newer goes through the Methodology gate (section 21).
- Platform over dependencies — Each release absorbs former library
territory (
structuredClone,groupBy, Set operations, Intl). When the platform covers it, drop the dependency. - Defensive by default — Validate inputs, guard null results, abort stale requests, remove listeners.
- XSS-safe DOM writes —
textContentis the default; HTML-parsing APIs accept trusted markup only (Security).
2. Minimum Versions
| Technology | Minimum Version |
|---|---|
| JavaScript | ES2024 stable (target: ES2025); notes per feature |
| Node.js | 22+ (22.6+ for most ES2025; 23+ for Promise.try) |
Baseline-first policy: prefer APIs labeled Baseline on MDN. Features marked "Newly available" must be checked against the project's browserslist targets before use.
3. When to Use JavaScript
Use JavaScript for behavior the platform cannot declare:
| Case | Justification |
|---|---|
| Dynamic interactivity | State-driven UI beyond CSS toggles |
| DOM manipulation | Create/update content dynamically |
| Fetch/AJAX | Get/send data without reloading |
| Client-side validation | Real-time feedback and submission |
| Browser APIs | Storage, clipboard, observers, etc. |
Do NOT use JavaScript where declarative alternatives exist — they are more robust, accessible and work without JS:
| Instead of... | Use... |
|---|---|
| Simple animations | CSS Transitions/Animations |
| Simple tooltips | CSS-only or title |
| Simple modals | HTML <dialog> |
| Simple accordions | <details>/<summary> |
| Simple carousels | CSS scroll snap |
| Simple dropdowns | HTML <select> |
4. Preferred APIs
| Instead of... | Use... |
|---|---|
var |
const (default), let (if reassigned) |
function expressions |
Arrow functions when appropriate |
| Callbacks | async/await + Promise |
| String concatenation | Template literals `hello ${name}` |
$.ajax |
fetch |
for loops |
map/filter/reduce (for...of when awaiting) |
JSON.parse(JSON.stringify()) |
structuredClone() |
setTimeout-driven animation |
CSS transitions |
| JS animation when unavoidable | Web Animations API (section 17) |
| Scroll/viewport detection | IntersectionObserver (section 16) |
5. Modern Data Structures
Rule: plain objects stay the default for simple string-keyed records;
use Map for keys of any type, Set for uniqueness and WeakMap for
object-keyed metadata that must not leak.
const users = new Map(); // keys of any type
users.set(42, { name: "Anna" });
users.get(42); // { name: "Anna" }
for (const [key, value] of users) {} // insertion order
const unique = [...new Set([1, 2, 2, 3])]; // [1, 2, 3]
// WeakMap — object keys; entries vanish with them (no GC leak)
const metadata = new WeakMap();
metadata.set(document.querySelector(".card"), { renderedAt: Date.now() });
Set operations (ES2025 · Baseline 2024) replace hand-rolled set algebra and utility-library helpers:
const a = new Set([1, 2, 3, 4]);
const b = new Set([3, 4, 5, 6]);
a.union(b); // Set(6) {1, 2, 3, 4, 5, 6}
a.intersection(b); // Set(2) {3, 4}
a.difference(b); // Set(2) {1, 2}
a.symmetricDifference(b); // Set(4) {1, 2, 5, 6}
a.isSubsetOf(b); // false
a.isSupersetOf(b); // false
a.isDisjointFrom(b); // false
6. Language APIs (ES2022 → ES2025)
Promise.withResolvers() (ES2024) externalizes resolve/reject
when bridging callback-based code. Promise.try() (ES2025 · Baseline
2025) runs a sync-or-async function and always returns a Promise —
synchronous throws become rejections with no manual try/catch. Ideal for
middleware and plugin hooks (Node 23+, see Minimum Versions):
const { promise, resolve, reject } = Promise.withResolvers();
externalCallback((err, data) => (err ? reject(err) : resolve(data)));
const result = Promise.try(task); // sync throw → rejection
Object.groupBy() / Map.groupBy() (ES2024) — plain object for string keys, Map otherwise:
const products = [
{ name: "Laptop", category: "tech", price: 1200 },
{ name: "Shirt", category: "clothing", price: 30 },
];
Object.groupBy(products, (p) => p.category);
Map.groupBy(products, (p) =>
p.price < 50 ? "budget" : p.price < 500 ? "mid" : "premium");
structuredClone() (Baseline 2022) deep-clones Date, Map, Set, RegExp, ArrayBuffer — but not functions, Symbols or DOM nodes:
const cloned = structuredClone({ date: new Date(), tags: ["js"] });
cloned.tags.push("ts"); // original untouched
Immutable array methods (ES2023 · Baseline 2023) return copies and
never mutate the source — prefer them over sort()/reverse()/splice()
in shared state:
const arr = [3, 1, 2];
arr.toSorted(); // [1, 2, 3]
arr.toReversed(); // [2, 1, 3]
arr.toSpliced(0, 1); // [1, 2]
arr.with(0, 9); // [9, 1, 2] — arr untouched throughout
Object.hasOwn() (ES2022) is the safe own-property check — works on
Object.create(null) objects where hasOwnProperty throws.
findLast()/findLastIndex() (ES2023) search from the end:
const obj = Object.create(null);
obj.name = "test";
Object.hasOwn(obj, "name"); // true
[1, 2, 3, 4, 5].findLast((n) => n % 2 === 0); // 4
[1, 2, 3, 4, 5].findLastIndex((n) => n % 2 === 0); // 3
Error.cause (ES2022) preserves the original error when rethrowing with context:
try {
await loadDashboard();
} catch (error) {
throw new Error("Dashboard failed", { cause: error });
}
7. Advanced Patterns
Reach for these only when the simpler tool genuinely does not fit.
Proxy + Reflect intercepts writes centrally instead of sprinkling
validation at call sites. Intl.Segmenter (Baseline 2023) gives
locale-aware word/sentence/grapheme boundaries — split("") breaks
emoji and accents:
const validator = {
set(target, key, value) {
if (key === "age" && (typeof value !== "number" || value < 0)) {
throw new TypeError("age must be a positive number");
}
return Reflect.set(target, key, value);
},
};
new Proxy({}, validator).age = -5; // TypeError
const graphemes = new Intl.Segmenter("en", { granularity: "grapheme" });
[...graphemes.segment("👍🏽")].length; // 1 grapheme, not two code points
RegExp.escape (ES2025 · Baseline 2025) escapes regex syntax before embedding untrusted strings in dynamic patterns — prevents broken regexes and regex injection. Newly available: verify runtime support:
const userInput = "price is $5.00 (USD)";
new RegExp(userInput); // ❌ $ . ( ) are special characters
new RegExp(RegExp.escape(userInput)).test(userInput + " today"); // ✅ true
Iterator helpers (ES2025 · Baseline 2025) give lazy
map/filter/take/drop/zip directly on iterators — no
intermediate arrays, works on infinite generators:
function* naturals() {
let i = 1;
while (true) yield i++;
}
naturals().take(5).toArray(); // [1, 2, 3, 4, 5]
naturals().drop(10).take(3).toArray(); // [11, 12, 13]
[...naturals().filter((n) => n % 2 === 0).take(3)]; // [2, 4, 6]
Iterator.zip(["Anna", "Louis"], [25, 30]).toArray();
// [["Anna", 25], ["Louis", 30]]
8. ES Modules
Standard layout: src/js/main.js entry point plus feature modules
(editor.js) and shared helpers (utils.js). Module scripts execute
deferred — no DOMContentLoaded wrapper needed. import.meta.url
exposes the current module URL when required:
// editor.js
export function initEditor() { /* ... */ }
export const EDITOR_CONFIG = { maxLength: 10000 };
// main.js
import { initEditor } from "./editor.js";
initEditor();
Dynamic import() loads on demand for code splitting — the bundler emits a separate chunk:
button.addEventListener("click", async () => {
const { showToast } = await import("./toast.js");
showToast("Saved");
});
Import attributes (ES2025) — with { type } makes the runtime
validate the type before evaluating (MIME-confusion safeguard); JSON
modules expose only default:
import config from "./config.json" with { type: "json" };
const { default: pkg } = await import("./pkg.json", {
with: { type: "json" },
});
Import maps resolve bare specifiers in the browser without bundler:
<script type="importmap">
{ "imports": { "date-fns": "/vendor/date-fns.esm.js" } }
</script>
9. DOM Selection
const element = document.querySelector(".class"); // first match
const elements = document.querySelectorAll(".class"); // all matches
const el = document.getElementById("my-id"); // fastest by id
element.dataset.property = "value"; // data-property
querySelectorreturnsnullon no match — always guard before use (see Defensive Programming, section 13).
10. DOM Manipulation
Rule: build content with createElement + textContent. HTML-
parsing APIs (innerHTML, insertAdjacentHTML) are reserved for
static, developer-owned markup — never user data
(Security):
const div = document.createElement("div");
div.className = "card";
div.textContent = userInput; // ✅ rendered as text, XSS-safe
container.innerHTML = '<div class="ad">Static markup</div>'; // trusted only
container.insertAdjacentHTML("beforeend", tpl);
// positions: beforebegin | afterbegin | beforeend | afterend
Insertion family — append accepts strings and nodes;
replaceChildren clears and fills in one operation:
parent.append("Text", element);
parent.prepend(element);
sibling.before(element);
sibling.after(element);
oldElement.replaceWith(newElement);
parent.replaceChildren(a, b);
element.remove();
Clone inert <template> content (see HTML); prefer
direct ARIA IDL properties over setAttribute; drive Popover with its
methods instead of manual show/hide state:
const card = document.querySelector("#tpl-card").content.cloneNode(true);
card.querySelector(".title").textContent = title;
container.append(card);
button.ariaExpanded = "true";
popover.showPopover(); // also hidePopover() / togglePopover()
11. Events
Listener options and cleanup — { once, passive } for one-shot and
scroll-path handlers; an AbortController signal removes a whole group
of listeners in one call:
element.addEventListener("scroll", onScroll, { passive: true });
dialog.addEventListener("close", onClose, { once: true });
const controller = new AbortController();
window.addEventListener("resize", onResize, { signal: controller.signal });
controller.abort(); // removes every listener bound to this signal
No inline handlers — onclick couples markup to globals and fights
CSP (Security). Bind via data-action; delegate
on a stable parent with closest() for dynamic children:
// Markup: <button type="button" data-action="save">Save</button>
document.querySelectorAll("[data-action]").forEach((el) => {
el.addEventListener("click", handleAction);
});
list.addEventListener("click", (event) => {
const item = event.target.closest(".list__item");
if (!item) return; // click landed outside an item
});
Pointer events unify mouse, touch and pen; pair custom gestures with
CSS touch-action: none when scrolling must be prevented:
element.addEventListener("pointerdown", (e) => {
console.log(e.pointerType, e.clientX, e.clientY); // mouse | touch | pen
});
Keyboard events — only non-native interactive elements
(div[role="button"][tabindex="0"]) need manual Enter/Space handling.
Real <button>/<a> already fire click: adding keydown fires the
action twice:
custom.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
activate(custom);
});
Focus management and the full keyboard policy live in the Accessibility skill.
12. Fetch
Always check response.ok — fetch rejects on network failure only;
HTTP error statuses resolve normally and must be thrown explicitly:
async function getJSON(url, options) {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
const data = await getJSON("/api/items"); // throws on 4xx/5xx/network
// POST JSON — same contract
await getJSON("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(item),
});
Auth headers and cookies belong to Auth; status-code contracts to API Design.
Cancellation and timeout — caller-driven abort, or a fixed timeout with no setTimeout bookkeeping:
const controller = new AbortController();
fetch(url, { signal: controller.signal }).catch((error) => {
if (error.name === "AbortError") { /* expected on abort */ }
});
controller.abort();
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
// throws TimeoutError after 5s
Retry with exponential backoff:
async function fetchWithRetry(url, retries = 3, baseDelay = 500) {
for (let attempt = 0; ; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
if (attempt >= retries - 1) throw error;
await new Promise((r) => setTimeout(r, baseDelay * 2 ** attempt));
}
}
}
Streaming large responses — process chunks progressively instead of buffering the full body:
const reader = (await fetch("/large-file.json")).body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
processChunk(decoder.decode(value, { stream: true }));
}
13. Defensive Programming
Validate at boundaries, fail fast, never trust shape:
if (!data || typeof data !== "object") return null;
const city = user?.address?.city; // optional chaining
obj?.method?.();
const count = items?.length ?? 0; // ?? falls back only on null/undefined
const button = document.querySelector(".btn-submit");
if (button) button.addEventListener("click", handleSubmit);
14. Storage
Web Storage holds strings only — always wrap values in JSON. Private modes throw on access, so probe availability first. Never store secrets or tokens in Web Storage — see Auth:
localStorage.setItem("user", JSON.stringify({ name: "John" }));
const user = JSON.parse(localStorage.getItem("user") ?? "{}");
localStorage.removeItem("user");
sessionStorage.setItem("draft", "value"); // cleared when tab closes
function hasStorage() {
try {
localStorage.setItem("__t", "1");
localStorage.removeItem("__t");
return true;
} catch {
return false;
}
}
IndexedDB — use the idb wrapper. For structured or large
client-side data use the idb package (tiny, promised, transaction-aware)
instead of hand-writing event-based wrappers around IndexedDB's callback
API:
import { openDB } from "idb";
const db = await openDB("myapp", 1, {
upgrade(db) { db.createObjectStore("items", { keyPath: "id" }); },
});
await db.put("items", { id: 1, name: "test" });
await db.get("items", 1);
15. Internationalization — Intl
Never hand-format dates, numbers or currency; Intl implements locale rules. Create formatters once and reuse them — construction is costly:
new Intl.DateTimeFormat("es-ES", {
year: "numeric", month: "long", day: "numeric",
}).format(new Date()); // "24 de agosto de 2026"
new Intl.NumberFormat("es-ES", {
style: "currency", currency: "EUR",
}).format(1234.56); // "1.234,56 €"
new Intl.NumberFormat("en-US", { style: "percent" }).format(0.75);
new Intl.RelativeTimeFormat("es", { numeric: "auto" }).format(-1, "day");
16. IntersectionObserver
Canonical viewport-detection pattern (reveal effects, lazy loading):
const target = document.querySelector(".lazy-load");
if (!target) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add("visible");
observer.unobserve(entry.target); // fire once
}
},
{ rootMargin: "0px", threshold: 0.1 },
);
observer.observe(target);
17. Animation
CSS owns animation (transitions, keyframes — see
CSS). JavaScript enters only when CSS cannot express
the behavior (physics, gesture sync), via the Web Animations API. Animate
only transform/opacity — layout properties force reflow every frame
(Performance):
const animation = element.animate(
[{ opacity: 0, transform: "translateY(20px)" }, { opacity: 1 }],
{ duration: 300, easing: "ease-out", fill: "forwards" },
);
animation.cancel(); // release when finished or unmounted
18. Browser APIs
All of these are progressive enhancements: feature-detect before use.
Clipboard requires secure context; reading is permission-gated:
await navigator.clipboard.writeText("Text to copy");
const text = await navigator.clipboard.readText();
Web Share delegates to the OS share sheet:
if (navigator.share) {
await navigator.share({ title: "My article", url: location.href });
}
BroadcastChannel — messaging between tabs of the same origin:
const channel = new BroadcastChannel("app-updates");
channel.postMessage({ type: "LOGOUT" }); // sender tab
channel.addEventListener("message", (event) => { // receiver tabs
if (event.data.type === "LOGOUT") logout();
});
WebSocket connections must reconnect with exponential backoff:
function connectWebSocket(url, { retries = 5, baseDelay = 1000 } = {}) {
let attempt = 0;
const ws = new WebSocket(url);
ws.addEventListener("open", () => (attempt = 0));
ws.addEventListener("message", (e) => showMessage(JSON.parse(e.data)));
ws.addEventListener("close", () => {
if (attempt >= retries) return;
const delay = baseDelay * 2 ** attempt++; // 1s, 2s, 4s, ...
setTimeout(() => connectWebSocket(url, { retries, baseDelay }), delay);
});
return ws;
}
View Transitions (same-document) smooth SPA state changes without
libraries. Guard the API, and inject only markup you control — replacing
body with fetched HTML containing user content is an XSS vector and
wipes listeners and state:
if (!document.startViewTransition) return renderDirectly();
const transition = document.startViewTransition(async () => {
document.body.innerHTML = await fetch(url)
.then((r) => r.text()); // ⚠️ trusted, sanitized markup ONLY
});
await transition.finished;
Popover lifecycle — declarative setup belongs to HTML
(HTML); JS reacts to state changes via beforetoggle:
menu.addEventListener("beforetoggle", (event) => {
if (event.newState === "open") focusFirstItem(menu);
});
UUIDs and analytics beacons — crypto.randomUUID() is Baseline
2023; sendBeacon survives page unload where fetch does not:
const id = crypto.randomUUID();
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
navigator.sendBeacon("/api/analytics", JSON.stringify({ event: "exit" }));
}
});
19. Performance
DOM-layer rules only. Read/write batching, layout-thrash avoidance and measurement budgets are owned by the Performance skill — reference, don't duplicate.
Style changes via classList — per-property style.* writes each
trigger invalidation, and classes never clobber existing inline styles.
style.cssText REPLACES every inline style: only safe when you own all
of them.
el.style.width = "100px"; // ❌ write-per-property
el.classList.add("is-open"); // ✅ one toggle, batched invalidation
requestIdleCallback defers non-urgent work until the browser is idle
(fallback: setTimeout(drain, 0)):
function drain(deadline) {
while (deadline.timeRemaining() > 0 && tasks.length > 0) tasks.shift()();
if (tasks.length > 0) requestIdleCallback(drain, { timeout: 2000 });
}
requestIdleCallback(drain);
requestAnimationFrame with delta time — frame-rate-independent animation for cases CSS/WAAPI cannot express:
let last;
let position = 0; // px
const SPEED = 0.1; // px per ms
function animate(timestamp) {
position += SPEED * (timestamp - (last ?? timestamp));
last = timestamp;
el.style.transform = `translateX(${Math.min(position, 200)}px)`;
if (position < 200) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
Debounce vs throttle — debounce waits for quiet (search inputs); throttle caps the rate (scroll, resize):
function debounce(fn, delay) {
let id;
return (...args) => {
clearTimeout(id);
id = setTimeout(() => fn(...args), delay);
};
}
function throttle(fn, limit) {
let ready = true;
return (...args) => {
if (!ready) return;
ready = false;
fn(...args);
setTimeout(() => (ready = true), limit);
};
}
input.addEventListener("input", debounce(search, 300));
window.addEventListener("scroll", throttle(onScroll, 100));
20. Common Dependencies
Vanilla JavaScript typically requires none. When a documented gap exists:
| Package | Use |
|---|---|
| date-fns | Date math/timezones beyond Intl formatting |
| idb | Promised IndexedDB access (section 14) |
With structuredClone, Object.groupBy, Set operations, iterator
helpers and the ES2023 array methods, lodash is no longer needed in most
vanilla projects.
21. Methodology
Before using any API or pattern not documented in this skill:
- MCP Context7 (priority) — resolve the library/spec and query docs.
- MDN Web Docs — confirm existence, Baseline status, browser support.
- Can I Use — verify against the project's browserslist targets.
- Official spec — TC39 proposals and WHATWG/W3C standards for edge cases.
Hard rule: if it is neither in this skill nor verifiable against two authoritative sources, DO NOT USE IT. Document it as an assumption or risk to the orchestrator.
22. Prohibitions
- No frameworks (Vue, Svelte, Angular) or React in vanilla projects without explicit authorization — see React
- No TypeScript in vanilla projects unless required — see TypeScript
- No
var, no undeclared globals, no leftoverconsole.log/debugger - No jQuery for selection or basic DOM work
- No JavaScript where CSS/HTML solve it (animation, tooltips, dialogs)
- No inline event handlers (
onclick=) — bind withaddEventListener - No
innerHTML/insertAdjacentHTMLwith user-controlled data (XSS) - No
evalornew Function(code injection) - No
JSON.parse(JSON.stringify())cloning — usestructuredClone() - No nested
Promiseconstructors wherewithResolvers()fits - No
keydownEnter/Space handling on native<button>/<a>(they already fireclick) - No hand-rolled IndexedDB promise wrappers — use
idb - No unverified or experimental APIs — pass the Methodology gate first
23. References
Structure, forms, declarative components: HTML Styling, transitions, scroll effects: CSS Focus and keyboard policy: Accessibility Layout thrash, batching, CWV: Performance CSP, XSS sanitization, injection: Security Contracts, status codes, pagination: API Design Tokens, cookies, credential storage: Auth Type safety and gradual typing: TypeScript Component architectures: React Hosting and CI/CD: Deploy
Removed in v2.0.0 (audit): WeakRef/FinalizationRegistry, Array.fromAsync,
JSON.parse context.source, Uint8Array base64/hex, File System Access
API, the layout-thrash example (owned by Performance) and the hand-rolled
IndexedDB wrapper. History: git and .backup/SKILL.md.bak.
Last updated: 2026-08