I still remember the first time I broke a production page by changing a single class name in the wrong spot. The UI looked fine on my machine, but on real data a hidden panel never opened again. That moment taught me a core truth about the web: the DOM is a living document, and every edit is a real change to the user’s experience. When you manipulate HTML elements with JavaScript, you are editing that document live.
I want you to feel confident making those edits. I will walk through how I select elements, read and update content, change styles, manage attributes, create and remove nodes, and respond to user actions. You will also see how I avoid common pitfalls, keep performance predictable, and decide when direct DOM work is the right call versus when I reach for a framework. Along the way I’ll show complete examples you can run as-is, plus the mental models I use in 2026 to keep my code sturdy and easy to maintain.
The DOM as a living document
I think about the DOM like a shared whiteboard in a busy team room. Each HTML element is a sticky note that can be moved, edited, or replaced. JavaScript is the hand that moves them. If you touch the wrong note, the whole plan changes. If you edit the same note in two places, you can create conflicting messages. That’s why I start with a disciplined mental model: every DOM change should be intentional, traceable, and easy to reverse.
In practical terms, that means I separate “finding” from “editing.” I find the element, I validate it exists, then I make changes in a small, focused block. I also avoid changes during layout thrash. Updating a class list, for example, is generally safer than modifying dozens of inline styles because it keeps logic and presentation separated.
Another 2026 habit: I keep a short list of DOM writes grouped together. In Chrome DevTools I can verify the change timeline and spot any unexpected reflows. Even for small scripts, this habit pays off when a page gets complex.
Selecting elements without surprises
Element selection is the foundation. You can’t edit what you can’t safely find. The classic methods still matter, but I also rely on modern selectors when they improve clarity.
ID, class, and tag selection
When I want exactly one element and I control the markup, I use an ID. That gives me a single element and a clear anchor point in both HTML and JS.
Profile Card
Jordan Lee
Platform Engineer
const card = document.getElementById("profile-card");
if (card) {
card.style.border = "2px solid #1c7c54";
}
If multiple elements share a class or tag, I expect a collection. I loop through it carefully and avoid assumptions about length.
const roles = document.getElementsByClassName("role");
for (const role of roles) {
role.textContent = role.textContent + " • Remote";
}
Query selectors for clarity
When the selector itself communicates intent, I use querySelector and querySelectorAll. I see them as a readable contract: “Find the primary button in the pricing card.”
const primaryButton = document.querySelector(".pricing-card .button.primary");
if (primaryButton) {
primaryButton.setAttribute("aria-pressed", "false");
}
querySelectorAll returns a static NodeList, which is helpful when you need a stable snapshot of matching nodes. If I need live updates as the DOM changes, I stick to getElementsByClassName or getElementsByTagName, which are live HTMLCollections.
Traditional vs modern selection
In 2026, I still choose between classic and modern methods based on clarity and stability. Here is how I decide.
Traditional method
When I pick it
—
—
getElementById
ID when I own markup; selector when it clarifies structure
getElementsByClassName
querySelectorAll for a snapshot; class method for live list
getElementsByTagName
tag method for older code; selector when filtering## Reading and changing content safely
Content updates are simple to write and easy to misuse. I pick the property that matches the job: textContent for text, innerHTML for trusted HTML, and value for form inputs.
textContent vs innerHTML
I treat textContent as the default. It avoids HTML parsing and protects against injected markup. I only use innerHTML for controlled, trusted strings.
const status = document.getElementById("status");
if (status) {
status.textContent = "Sync complete"; // safe by default
}
If I do use innerHTML, I guard the input and keep it small.
const notice = document.getElementById("notice");
if (notice) {
const label = "Maintenance"; // trusted source
notice.innerHTML = ${label} starts at 9:00 PM;
}
Working with forms
Forms are where I see the most bugs. textContent does not affect input value. You must use .value.
const input = document.getElementById("project-name");
const save = document.getElementById("save");
save.addEventListener("click", () => {
if (input) {
console.log("Saving:", input.value);
}
});
Avoiding unexpected reflows
If I need to update many elements, I batch the updates. I also avoid reading layout properties between writes because it forces the browser to sync layout. I aim for predictable timing: small edits are usually sub‑millisecond, while dozens of inline changes can hit 10–20ms on mid‑range devices.
Styling and classes with intent
Inline styles are tempting, but class changes are easier to maintain and reason about. I treat inline styles as an escape hatch for rare cases like computed values.
ClassList for state changes
A class name is a compact state signal. I prefer classList over className because it is explicit and avoids accidental overrides.
const toggle = document.getElementById("menu-toggle");
const menu = document.getElementById("side-menu");
toggle.addEventListener("click", () => {
const isOpen = menu.classList.toggle("menu--open");
toggle.setAttribute("aria-expanded", String(isOpen));
});
Inline styles for computed values
When you need a computed value, inline styles are fine. I still keep them isolated and minimal.
const meter = document.getElementById("upload-meter");
if (meter) {
const progress = 62; // percent
meter.style.width = progress + "%";
meter.style.transition = "width 180ms ease";
}
CSS variables
A modern pattern I rely on is CSS variables. You can set them in JS and let CSS do the rest.
.card { background: hsl(var(--accent-hue) 65% 45%); }
Team Space
const card = document.getElementById("accent-card");
if (card) {
card.style.setProperty("--accent-hue", "210");
}
This gives you a clean division: JS sets parameters, CSS handles presentation.
Attributes, data, and accessibility
Attributes are the interface between HTML and JavaScript. I view them as the public API for an element. When I need extra data, I use data-* attributes. When I need state to be communicated to assistive tech, I use ARIA attributes.
Reading and writing attributes
I prefer getAttribute and setAttribute when the attribute is not mapped to a property, like aria- or data-.
const banner = document.querySelector(".promo-banner");
if (banner) {
const campaign = banner.getAttribute("data-campaign");
banner.setAttribute("aria-live", "polite");
console.log("Campaign:", campaign);
}
dataset for structured data
dataset is a clean way to access data-* attributes as a JS object.
const planButton = document.querySelector(".plan");
if (planButton) {
const { plan, price } = planButton.dataset;
console.log(Selected ${plan} at $${price});
}
Accessibility checks
When I open or close panels, I update aria-expanded and aria-hidden. It is a small change, but it keeps UI state visible to assistive tech.
const panel = document.getElementById("help-panel");
const trigger = document.getElementById("help-trigger");
trigger.addEventListener("click", () => {
const isOpen = panel.classList.toggle("panel--open");
panel.setAttribute("aria-hidden", String(!isOpen));
trigger.setAttribute("aria-expanded", String(isOpen));
});
Creating, inserting, and removing nodes
Editing existing elements is common, but sometimes you need to create new nodes. I create elements with document.createElement, set attributes and text, then insert them where needed. I avoid building large strings of HTML when I can build nodes directly because it reduces parsing overhead and keeps logic clear.
A complete example: adding tasks
This example is fully runnable and shows creation, insertion, and removal while keeping the DOM in a clean state.
Task List
body { font-family: system-ui, sans-serif; }
.task { display: flex; gap: 8px; margin: 6px 0; }
.task button { margin-left: auto; }
Team Tasks
const form = document.getElementById("task-form");
const input = document.getElementById("task-input");
const list = document.getElementById("task-list");
form.addEventListener("submit", (event) => {
event.preventDefault();
const title = input.value.trim();
if (!title) return;
const item = document.createElement("li");
item.className = "task";
const label = document.createElement("span");
label.textContent = title;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "Remove";
remove.addEventListener("click", () => item.remove());
item.appendChild(label);
item.appendChild(remove);
list.appendChild(item);
input.value = "";
input.focus();
});
When fragments help
If I need to insert many nodes at once, I use a DocumentFragment to minimize reflows.
const fragment = document.createDocumentFragment();
const cities = ["Denver", "Lisbon", "Osaka", "Nairobi"];
for (const city of cities) {
const li = document.createElement("li");
li.textContent = city;
fragment.appendChild(li);
}
const list = document.getElementById("city-list");
list.appendChild(fragment);
On medium pages, this pattern keeps insertion time typically in the 2–8ms range instead of 15–30ms from repeated single appends.
Event listeners and event delegation
Interactivity is where DOM work feels alive. I attach listeners using addEventListener, keep handlers small, and use event delegation when I have many similar elements.
Direct listeners for explicit targets
I use direct listeners for single elements or when behavior is unique.
const share = document.getElementById("share");
share.addEventListener("click", () => {
console.log("Shared link copied");
});
Delegation for lists
Delegation keeps my code smaller and avoids attaching hundreds of listeners.
- New comment
- Build succeeded
- New follower
const list = document.getElementById("notifications");
list.addEventListener("click", (event) => {
const item = event.target.closest("li");
if (!item) return;
console.log("Selected notification:", item.dataset.id);
});
Keyboard and pointer events
In 2026, I treat keyboard support as a core feature, not an add-on. If a card is clickable, I make it respond to Enter and Space.
const card = document.getElementById("report-card");
card.addEventListener("click", openReport);
card.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openReport();
}
});
Common mistakes and how I avoid them
I still see the same issues in code reviews, so I keep a short checklist.
1) Assuming a node exists. I always null‑check before editing. A missing node should not crash your script.
2) Using innerHTML for user input. This invites injection. Use textContent unless you control the string.
3) Overwriting classes. className = "new" wipes existing classes. Use classList.add or toggle.
4) Binding too many listeners. For large lists, use delegation.
5) Ignoring accessibility attributes. Updates should reflect in ARIA attributes when they represent state.
When not to manipulate directly
Direct DOM work is great for small, focused scripts. I avoid it when:
- The UI state is complex and shared across many components.
- The same UI is rendered in multiple places with identical logic.
- The page requires consistent server‑side rendering and hydration.
In those cases, I reach for a framework or a component system. In 2026, I often pair a lightweight framework with web components for stable UI islands. I also use AI‑assisted refactors in my editor to spot repeated DOM patterns and suggest component boundaries. The key is to pick the tool that reduces long‑term maintenance, not just the fastest to code today.
Performance and safety habits I use in 2026
I treat performance and safety as first‑class features. My daily habits are simple:
- I batch DOM reads and writes to avoid forced layout.
- I use
requestAnimationFramefor visual updates tied to animation. - I avoid heavy
innerHTMLupdates in hot paths. - I guard user input and encode text before displaying it.
- I watch the Performance panel in DevTools after any significant change.
Here is a small example that uses requestAnimationFrame to update a progress bar smoothly without flooding the browser:
const bar = document.getElementById("progress");
let target = 0;
let current = 0;
function update() {
current += (target - current) * 0.12;
bar.style.width = ${current.toFixed(2)}%;
if (Math.abs(target - current) > 0.1) {
requestAnimationFrame(update);
}
}
function setProgress(next) {
target = Math.max(0, Math.min(100, next));
requestAnimationFrame(update);
}
setProgress(68);
The math keeps the animation smooth and stops itself once the difference is small enough.
DOM readiness and script timing
A surprising number of DOM errors are really timing errors. If your script runs before the DOM exists, your selectors return null. I handle this with simple, explicit timing rules.
Place scripts at the end
When I control the HTML, I put scripts right before . That’s the most straightforward approach.
Use DOMContentLoaded
When scripts must be in the or loaded asynchronously, I wait for the DOM to finish parsing.
document.addEventListener("DOMContentLoaded", () => {
const panel = document.getElementById("welcome-panel");
if (panel) panel.classList.add("ready");
});
Defer when possible
If I can, I add defer to the script tag. That keeps HTML parsing fast and guarantees the DOM exists when the script runs.
This simple change saves me from many race conditions.
Inserting HTML safely and predictably
Sometimes I do want to insert HTML. When I do, I use the smallest safe surface area.
Prefer insertAdjacentHTML for targeted inserts
I use it for well‑formed, trusted strings in a specific spot.
const container = document.getElementById("alerts");
if (container) {
container.insertAdjacentHTML("afterbegin", "
Saved");
}
It avoids blowing away event listeners that would be lost if I replaced innerHTML of a parent.
Build nodes when structure is complex
If the HTML is more than a few tags, I switch to createElement. It keeps my logic explicit, and I can attach listeners before inserting.
const toast = document.createElement("div");
toast.className = "toast";
toast.textContent = "Message sent";
container.appendChild(toast);
Templates and cloning for repeated UI
When I need repeated UI blocks, I reach for the element and content.cloneNode(true). It’s fast, readable, and keeps markup in HTML where it’s easy to review.
<span class="username">
<button class="userremove" type="button">Remove
const template = document.getElementById("user-row");
const list = document.getElementById("users");
const names = ["Avery", "Maya", "Rafael"];
for (const name of names) {
const node = template.content.cloneNode(true);
node.querySelector(".username").textContent = name;
node.querySelector(".userremove").addEventListener("click", (e) => {
e.currentTarget.closest(".user").remove();
});
list.appendChild(node);
}
This keeps structure and behavior organized without a framework.
Working with classes at scale
Once a page has multiple features, class management can get messy. I follow two simple conventions:
- State classes start with
is-orhas-. Example:is-open,has-error. - Variant classes start with
--. Example:card--compact.
This helps me scan the DOM and see the difference between structure and state. It also makes classList usage self‑documenting.
const panel = document.getElementById("filters");
function openPanel() {
panel.classList.add("is-open");
panel.classList.remove("has-error");
}
The difference between properties and attributes
This detail trips people up all the time: not every attribute has a matching property, and properties don’t always update the attribute. I keep these mental notes:
valueis a property on inputs. Changing the attribute may not change the live input value.checkedandselectedare properties. Setting the attribute after render may not reflect UI state.aria-anddata-are attributes. UsesetAttributeordataset.
Here’s a tiny example that shows the correct approach for an input checkbox:
const box = document.getElementById("notify-me");
box.checked = true; // property, updates UI
box.setAttribute("aria-checked", "true"); // attribute, for accessibility
Measuring and debugging DOM changes
I don’t just edit; I verify. Two DevTools habits save me hours:
1) Use the Elements panel to confirm class and attribute changes. It tells me if my selection hit the correct node.
2) Use the Performance panel to confirm reflow costs. If I see layout spikes, I look for interleaved reads and writes.
For quick checks, I sometimes add a temporary debug outline to a changed element:
const card = document.getElementById("profile-card");
if (card) card.style.outline = "2px dashed #ff6b6b";
Then I remove it once I confirm the selection.
Real‑world scenario: dynamic product cards
Here’s a full example that combines selection, templating, attributes, and event handling in a way you might actually ship. It reads product data, renders cards, and handles “Add to cart” clicks with a single delegated listener.
Product Grid
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
.card { padding: 12px; border: 1px solid #ddd; border-radius: 8px; }
.cardprice { font-weight: 600; }
const products = [
{ id: "p1", name: "Studio Lamp", price: 59 },
{ id: "p2", name: "Desk Mat", price: 24 },
{ id: "p3", name: "Cable Kit", price: 18 }
];
const grid = document.getElementById("grid");
const fragment = document.createDocumentFragment();
for (const item of products) {
const card = document.createElement("div");
card.className = "card";
card.innerHTML = `
${item.name}
<p class="cardprice">$${item.price}
`;
fragment.appendChild(card);
}
grid.appendChild(fragment);
grid.addEventListener("click", (event) => {
const button = event.target.closest("button[data-id]");
if (!button) return;
const id = button.dataset.id;
console.log("Add to cart:", id);
button.disabled = true;
button.textContent = "Added";
});
I keep this pattern in my toolkit because it scales well from 3 cards to 300 without becoming unreadable.
Edge cases that bite in production
Here are the real-world issues I plan for before they show up as bugs:
- Shadow DOM elements are not reachable via global selectors. You must select inside the shadow root.
event.targetcan be a child element. Useclosestto normalize.- Elements can move or be replaced. If you store a reference to an element that later gets re-rendered, your reference becomes stale.
- NodeList is not always an array. Convert when you need array methods:
Array.from(nodeList). innerHTMLdestroys listeners. Replacing a parent’sinnerHTMLremoves all child listeners. I avoid that in interactive sections.
When in doubt, I write defensive code that checks for the element, logs a readable warning, and fails gracefully.
MutationObserver: reacting to DOM changes
Sometimes the DOM changes outside of your script—think widgets, ads, or injected content. In those cases, I use MutationObserver to react without polling.
const target = document.getElementById("feed");
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
console.log("New content inserted");
}
}
});
observer.observe(target, { childList: true, subtree: true });
I use this sparingly because it can create invisible work, but it’s perfect for watching dynamic containers.
Managing state without a framework
You can do more than you might think with a tiny amount of state management. I often keep a plain object and reflect changes into the DOM.
const state = { count: 0 };
const label = document.getElementById("count");
const btn = document.getElementById("increment");
function render() {
label.textContent = String(state.count);
}
btn.addEventListener("click", () => {
state.count += 1;
render();
});
render();
This keeps the logic explicit and avoids hidden side effects.
Comparing alternative approaches to the same task
There are usually multiple ways to do the same DOM task. I choose based on clarity and risk.
Simple approach
Why I choose it
—
—
appendChild
insertAdjacentHTML appendChild if I already have a node
classList.toggle
className = ... classList preserves other classes
createElement loop
innerHTML string createElement avoids event loss
dataset
getAttribute dataset for readabilityI keep these tradeoffs in mind so my choices are consistent.
Practical scenario: editable profile card
This example ties together reading values, updating text, and syncing accessibility attributes. It’s small enough to run locally and realistic enough to reuse.
Editable Profile
Alex Kim
Product Designer
const role = document.getElementById("role");
const input = document.getElementById("role-input");
const apply = document.getElementById("apply");
const status = document.getElementById("status");
apply.addEventListener("click", () => {
const next = input.value.trim();
if (!next) {
status.textContent = "Role cannot be empty";
return;
}
role.textContent = next;
status.textContent = "Role updated";
});
I like this because it demonstrates how a tiny UI becomes more reliable when you handle empty values and accessibility feedback.
Security and trust boundaries
The most dangerous DOM mistake is assuming content is safe. I draw a bright line between trusted strings and user input.
- Trusted: strings I control or generate from known sources.
- Untrusted: anything that comes from users, query params, or external APIs.
When I need to render untrusted content, I use textContent or sanitize it before using innerHTML. If I must accept HTML, I validate it first and keep the allowed tags minimal.
This isn’t about being paranoid; it’s about being intentional. A single unsafe innerHTML can become a persistent vulnerability.
Micro‑performance: reads, writes, and layout
I keep a tiny performance checklist for frequent DOM updates:
- Group all reads together. Example: read
offsetHeightandgetBoundingClientRectfirst. - Group all writes together. Example: set classes and styles after reading.
- Use transforms for animations.
transformandopacityare cheaper than changing layout.
Here is a read/write example that avoids forced layout:
const box = document.getElementById("box");
const height = box.offsetHeight; // read
// ... compute
box.style.transform = translateY(${height * 0.2}px); // write
This pattern stays fast even as the page grows.
When to refactor into components
If I see the same DOM operations repeating with minor differences, I refactor. A simple function is often enough.
function makeBadge(text, variant = "neutral") {
const badge = document.createElement("span");
badge.className = badge badge--${variant};
badge.textContent = text;
return badge;
}
const status = document.getElementById("status");
status.appendChild(makeBadge("Active", "success"));
This keeps DOM manipulations centralized and less error‑prone.
Troubleshooting checklist I use
When DOM updates don’t behave as expected, I walk through a fast checklist:
1) Did my selector return the correct element?
2) Is the script running after the DOM is ready?
3) Am I reading a property instead of an attribute (or the other way around)?
4) Did I replace an element and lose listeners?
5) Are there conflicting styles overriding my changes?
This list has saved me countless minutes of guessing.
A final mental model
I end where I started: the DOM is a living document. The best DOM code is clear, intentional, and reversible. When I manipulate HTML elements with JavaScript, I ask myself three questions:
- Is the selection safe and specific?
- Is the update minimal and readable?
- Will this still make sense in six months?
If the answers are yes, I move forward with confidence. If not, I refactor until the code is as stable as the UI I want to deliver.
You can absolutely build robust UI behavior with plain JavaScript. The keys are discipline, careful selection, and a steady respect for the fact that every DOM change is a change the user will experience in real time.


