JavaScript DOM Basics: Selecting, Reading, and Modifying the Page
The Document Object Model — DOM for short — is the tree of objects the browser builds from your HTML. JavaScript reads it, walks it, modifies it, and listens to it. Everything you do to make a web page interactive ultimately goes through the DOM.
This lesson is the foundation of Module 7. We cover what the DOM actually is, how to select nodes, how to read and write their contents and attributes, how to create and remove elements, and the modern APIs that have made DOM code in 2026 much cleaner than the jQuery-era patterns.
What the DOM is #
When the browser parses HTML, it produces a tree. Every tag, every text node, every comment becomes an object.
<body>
<h1>Hi</h1>
<p>Welcome</p>
</body>
becomes (simplified):
document
└── html
└── body
├── h1 (text: 'Hi')
└── p (text: 'Welcome')
The top of this tree is exposed to JavaScript as document. Every other node is reachable from it. The objects implement standardized interfaces — Element, HTMLElement, HTMLInputElement, Text, etc. — defined by the DOM spec.
The key insight: the DOM is a live tree, not a snapshot of your HTML. When you mutate it, the browser re-renders the affected parts. When the user types into an input, the DOM property updates.
Selecting nodes #
Four modern APIs cover ~99% of selection needs. The first two are by far the most used.
querySelector and querySelectorAll #
Both take any CSS selector and return matching elements.
const header = document.querySelector('h1'); // first match
const nav = document.querySelector('#main-nav'); // by id
const items = document.querySelectorAll('.todo'); // all matches (NodeList)
const nested = document.querySelector('main .card > h2');
querySelector returns the first match, or null if no match. querySelectorAll returns a static NodeList — iterable with for...of, has .length, but isn't a real array. Use Array.from(nodes) or [...nodes] if you need array methods.
const titles = Array.from(items).map(el => el.textContent);
These are slower than getElementById and friends in benchmark micro-tests, but the difference is negligible in real code. Always start with querySelector(All) and only specialize if profiling shows it matters.
getElementById #
For a single id-based lookup, this is the historical fastest path.
const form = document.getElementById('login-form');
Returns the element or null. No # prefix on the id — just the bare string.
closest #
Walks up the tree from an element looking for the nearest ancestor matching a selector.
button.addEventListener('click', (e) => {
const card = e.target.closest('.card');
if (card) handleCardClick(card);
});
Indispensable for event delegation (covered in Lesson 7.2).
matches #
Returns true if the element itself matches a selector.
if (e.target.matches('a.external')) {
e.preventDefault();
openInNewTab(e.target.href);
}
Reading content #
Three common properties:
el.textContent; // all text inside, ignoring HTML tags
el.innerHTML; // raw HTML string of the element's contents
el.value; // current value of form inputs (input, textarea, select)
textContentis what you want most of the time. Reads the visible text without parsing HTML.innerHTMLgives you the HTML string. Setting it parses the string as HTML — fast, but a security risk if the source isn't trusted (XSS).valueis for form fields.<input>,<textarea>,<select>— every other element ignores it.
For inputs, never confuse value with getAttribute('value'). The attribute is what was in the HTML at parse time; the property is what's currently in the input (which changes as the user types).
Writing content #
The inverse of reading:
el.textContent = 'New text'; // safe — text-only
el.innerHTML = '<strong>Bold!</strong>'; // parses HTML — be careful
input.value = 'autofilled'; // sets the current value
For anything beyond a single string, the modern API is replaceChildren (or build a fragment and append):
const li1 = document.createElement('li');
li1.textContent = 'First';
const li2 = document.createElement('li');
li2.textContent = 'Second';
list.replaceChildren(li1, li2); // efficient, atomic
replaceChildren swaps the entire children list in one DOM operation. Better for performance and clarity than removing children in a loop then appending.
Reading and writing attributes #
Attributes and properties are similar but not identical.
el.getAttribute('href'); // string, as in HTML
el.setAttribute('aria-pressed', 'true'); // string in, no coercion
el.removeAttribute('disabled');
el.hasAttribute('hidden');
Many HTML attributes are also exposed as JavaScript properties on the element:
img.src // same as img.getAttribute('src') — usually
link.href // same
input.disabled // BOOLEAN — true / false, not 'true' / 'false'
el.className // the class attribute as a string
el.id // the id
Property access is more ergonomic and gives you typed values (disabled is a boolean, not the string 'true'). Use properties when they exist; fall back to setAttribute for custom and data-* attributes.
dataset #
Any attribute starting with data- is exposed on el.dataset:
<div id="x" data-user-id="42" data-role="admin"></div>
x.dataset.userId; // '42' — note the camelCasing
x.dataset.role; // 'admin'
x.dataset.role = 'editor'; // sets data-role
dataset values are always strings — you'll need to Number(...) numerics. The auto-camelCase mapping bites if you forget.
Classes #
The modern API is classList:
el.classList.add('selected');
el.classList.remove('hidden');
el.classList.toggle('expanded'); // toggles, returns new state
el.classList.toggle('expanded', forceOn); // explicit on/off
el.classList.contains('active'); // boolean
el.classList.replace('old', 'new');
Is much better than the legacy el.className = el.className + ' selected' string manipulation. Always reach for classList.
Creating, inserting, and removing elements #
The modern API:
const card = document.createElement('div');
card.className = 'card';
card.textContent = 'Hello';
// Insert
parent.append(card); // append to end
parent.prepend(card); // insert at start
reference.before(card); // insert before sibling
reference.after(card); // insert after sibling
reference.replaceWith(card); // replace sibling
// Remove
card.remove(); // self-removes
These methods (append, prepend, before, after, replaceWith, remove) accept multiple arguments — they can take element nodes, document fragments, or strings (treated as text nodes).
list.append(li1, li2, 'or just text');
The older API (appendChild, insertBefore, removeChild) still works but is more verbose. The newer API is the modern choice.
DocumentFragments: batching DOM inserts #
If you're inserting many nodes, a DocumentFragment lets you build them off-DOM first and insert in one operation.
const frag = document.createDocumentFragment();
for (const item of items) {
const li = document.createElement('li');
li.textContent = item;
frag.append(li);
}
list.append(frag); // one DOM mutation, not N
This used to matter a lot for performance. Modern browsers' rendering pipelines are good enough that the difference is small for most workloads — but for hundreds of inserts in a loop, it's still measurable.
Traversing the tree #
When you have one node and need a neighbor:
el.parentElement
el.children // HTMLCollection — element children only
el.firstElementChild
el.lastElementChild
el.previousElementSibling
el.nextElementSibling
The Element variants skip text nodes and comments. The older parentNode, childNodes, firstChild, etc., include text and comment nodes — usually not what you want.
For reaching across the tree from an event target, closest (covered above) handles most cases.
When the DOM is ready #
Scripts that run before the page parses can't see elements yet. Three options:
- Put
<script>tags at the end of<body>(oldest pattern, still fine). - Use
<script defer>so the script runs after parsing. - Wait for the
DOMContentLoadedevent:
document.addEventListener('DOMContentLoaded', () => {
initApp();
});
In modern apps using <script type="module">, scripts are deferred by default — you can use the DOM right away.
When to skip the DOM and use a framework #
For static pages and progressive enhancement, raw DOM is great. For complex stateful UIs — dashboards, forms with many interdependent fields, real-time updates — you'll want a framework like Angular, React, Vue, or Svelte. The framework wraps DOM manipulation behind a declarative API; you describe the UI as a function of state, and it figures out the DOM changes.
Knowing the underlying DOM still matters even when using a framework. When something doesn't work, the answer is almost always at the DOM layer.
What's next #
Lesson 7.2 covers events — addEventListener, the bubbling/capturing model, event delegation, and preventDefault / stopPropagation. With selection + content + events, you have the full DOM toolkit.
Try it yourself #
The textContent vs innerHTML distinction is the most security-relevant one. Predict the output:
a.textContent = '<b>Hi</b>';
b.innerHTML = '<b>Hi</b>';browser_sandboxElement a shows the literal text <b>Hi</b> — angle brackets escaped, not parsed.Element
b shows a bold Hi — the string was parsed as HTML.That’s the whole XSS story in two lines. If the string comes from user input,
textContent is safe and innerHTML is dangerous.When in doubt, reach for textContent. It does the safe thing by default.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


