Build framework-free browser interfaces with deliberate DOM updates, resilient events, navigation, storage, observers, forms, and capability-safe platform APIs.
Query and update the DOM without losing semantic structure
Handle events and forms with cleanup and progressive enhancement
Use navigation, storage, and observer APIs safely
Cancel asynchronous browser work when it is no longer relevant
Detect capabilities and provide resilient fallbacks
The DOM is a live object model for the document. Query from the narrowest stable root, handle missing elements, and update properties that reflect the semantic state you intend.
Batch related changes and prefer textContent for plain text. Replacing large fragments with untrusted HTML creates security and state-preservation problems.
Scope queries to stable component roots
Handle nullable query results
Use textContent for untrusted plain text
const status = document.querySelector("[data-status]");
export function announce(message) {
if (!(status instanceof HTMLElement)) return;
status.textContent = message;
status.dataset.state = message ? "ready" : "idle";
}Events travel through capture, target, and bubble phases. Event delegation uses that propagation to handle dynamic descendants from one stable ancestor.
Do not stop propagation by default. Narrow the originating element with closest and confirm that it belongs to the delegated root before acting.
Use delegation for repeated or dynamic controls
Narrow event targets at runtime
Remove listeners when their owning lifecycle ends
const list = document.querySelector("[data-course-list]");
list?.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const button = target.closest("button[data-course-slug]");
if (!button || !list.contains(button)) return;
console.log(button.dataset.courseSlug);
});Native forms provide keyboard behavior, submission semantics, validation hooks, and a structured successful-controls model. FormData reads that model without manually tracking every input event.
Values may be strings or files, repeated names may have several entries, and unchecked controls may be absent. Narrow each value before treating it as application data.
Listen for submit on the form
Use FormData as the native serialization boundary
Validate string, file, and repeated values explicitly
const form = document.querySelector("form[data-enroll]");
form?.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const slug = data.get("course");
if (typeof slug !== "string" || slug.trim() === "") return;
console.log({ slug });
});Fetch resolves for HTTP error statuses, so callers must check response.ok before parsing a success body. Keep parsing and application validation as separate steps.
AbortController lets an owner cancel requests when input changes or a view is removed. Treat aborts as expected lifecycle events rather than user-facing failures.
Check HTTP status before consuming a success payload
Pass AbortSignal through every request boundary
Ignore expected AbortError failures
let activeController;
async function loadCourse(slug) {
activeController?.abort();
activeController = new AbortController();
const response = await fetch(`/api/courses/${slug}`, {
signal: activeController.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}URLs are durable, shareable state. Use URL and URLSearchParams instead of string concatenation so encoding and repeated values remain correct.
History changes should match the user's navigation model. Use pushState for a meaningful new entry, replaceState for correction, and listen for popstate to restore the view.
Keep shareable filters and selections in the URL
Choose push versus replace based on Back-button expectations
Render from location after popstate
function selectRoadmap(roadmapId) {
const url = new URL(window.location.href);
url.searchParams.set("roadmap", roadmapId);
history.pushState({ roadmapId }, "", url);
}
window.addEventListener("popstate", () => {
const roadmap = new URL(window.location.href).searchParams.get("roadmap");
console.log(roadmap);
});Web Storage is synchronous and stores strings, making it appropriate only for small non-sensitive preferences. IndexedDB is a better fit for larger structured or offline data.
Stored values can be stale, malformed, or modified outside the current code. Version and validate them before use, and never treat storage as an authorization boundary.
Store only non-sensitive client-owned preferences
Validate and version persisted values
Keep server-owned progress and entitlement on the server
const ROADMAPS = new Set(["react-frontend", "vanilla-js-typescript"]);
export function readRoadmapPreference() {
const value = localStorage.getItem("learning-roadmap");
return value && ROADMAPS.has(value) ? value : null;
}Observer APIs notify code when a browser-managed condition changes. IntersectionObserver handles visibility, ResizeObserver handles element sizing, and MutationObserver handles DOM changes.
Choose the observer that matches the signal and disconnect it with its owner. Polling layout or the DOM on a timer wastes work and can miss the browser's scheduling opportunities.
Observe the condition the browser already tracks
Keep callbacks small and batch follow-up work
Disconnect observers during cleanup
const observer = new IntersectionObserver(([entry]) => {
if (!entry?.isIntersecting) return;
entry.target.setAttribute("data-visible", "true");
observer.unobserve(entry.target);
}, { rootMargin: "200px" });
const section = document.querySelector("[data-lazy-section]");
if (section) observer.observe(section);Clipboard, file, media, and sharing APIs depend on secure contexts, browser support, user gestures, and permission policy. Feature detection only says an API exists; an operation can still be denied.
Start capability requests from a clear user action, explain why they are needed, and provide a fallback that preserves the core task.
Detect APIs before use
Request sensitive capabilities only from user intent
Handle denial without trapping the workflow
async function copyCourseLink(url, fallbackInput) {
try {
await navigator.clipboard.writeText(url);
return true;
} catch {
fallbackInput.value = url;
fallbackInput.hidden = false;
fallbackInput.select();
return false;
}
}Timers schedule work after a minimum delay; they are not precise clocks. Use elapsed timestamps for time calculations and clear timers when their owning interaction ends.
requestAnimationFrame schedules visual updates before a paint. Read layout together, then write changes together, to reduce repeated style and layout calculation.
Use timestamps instead of counting timer ticks
Use animation frames for paint-related changes
Cancel scheduled work during cleanup
let frame = 0;
export function scheduleProgress(element, percent) {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
element.style.setProperty("--progress", `${Math.max(0, Math.min(100, percent))}%`);
});
}Focus is part of application state. When an interface opens a modal surface, focus should enter it predictably, remain within the active context, and return to the control that opened it.
Prefer the native dialog element when it fits the interaction. It supplies modal focus behavior and inertness that are difficult to reproduce completely with generic containers.
Move focus only for a meaningful context change
Return focus to the triggering control
Use native dialog semantics when possible
const trigger = document.querySelector("[data-open-roadmap]");
const dialog = document.querySelector("dialog[data-roadmap]");
trigger?.addEventListener("click", () => dialog?.showModal());
dialog?.querySelector("[data-close]")?.addEventListener("click", () => {
dialog.close();
trigger?.focus();
});Every listener, observer, request, timer, and object URL has an owner. Give that owner one cleanup path so removed views cannot keep performing work or retaining memory.
Progressive enhancement begins with semantic content and working native behavior, then adds richer browser capabilities when available. The baseline should remain understandable under failure.
Pair every long-lived resource with cleanup
Use AbortSignal to coordinate compatible resources
Preserve a semantic baseline before enhancement
export function mountCourseFilters(root) {
const controller = new AbortController();
root.addEventListener("change", updateResults, {
signal: controller.signal,
});
window.addEventListener("popstate", updateResults, {
signal: controller.signal,
});
return () => controller.abort();
}