DOM Manipulation in JavaScript Explained — Select, Change, and React to HTML
Every interactive thing you've ever loved on a website — a button that changes colour when you click it, a form that shows an error message, a shopping cart that updates without reloading the page — all of that is powered by DOM manipulation. It's not a fancy library or a framework trick. It's a core browser feature that JavaScript has had since the very beginning, and it's the reason JavaScript became the language of the web.
Before DOM manipulation existed, if you wanted to update something on a page you had to reload the entire thing from the server. That made the web feel slow and clunky. The DOM solved that by giving JavaScript a live map of every element on the page. Change something in that map, and the browser instantly reflects it on screen. No reload. No waiting. That's the magic.
By the end of this article you'll know exactly how to find any element on a page, read and change its text or HTML, add and remove CSS classes to style things dynamically, create brand-new elements from scratch, and respond to user actions like clicks and keyboard input. These are the four or five skills that unlock every interactive UI pattern you'll ever build.
What the DOM Actually Is — The Live Map of Your Page
When a browser loads an HTML file, it doesn't just display it like a photo. It reads every tag and builds a tree structure in memory — a family tree where the tag is the grandparent, and are its children, and every paragraph, button, and image is a descendant somewhere in that tree. This in-memory tree is called the Document Object Model, or DOM.
The key word is 'live'. The DOM isn't a snapshot. It's a living representation of your page right now. When JavaScript changes a node in that tree, the browser immediately re-renders the affected part of the screen. There's no delay, no extra round trip to a server.
Every HTML element becomes a 'node' in this tree. Each node is a JavaScript object with properties you can read (element.textContent, element.id) and methods you can call (element.remove(), element.appendChild()). The global document object is your entry point into the whole tree — think of it as the root of the map. Every DOM operation you'll ever write starts with document.
// Open your browser's developer console and paste this in. // It shows you the DOM tree structure JavaScript sees. // 'document' is the root — the master object representing the whole page console.log(document.nodeName); // Logs: #document // The <html> element is the top of the visible tree const rootElement = document.documentElement; console.log(rootElement.nodeName); // Logs: HTML // <body> holds everything the user actually sees const pageBody = document.body; console.log(pageBody.nodeName); // Logs: BODY // Every element has a 'children' collection — its direct kids console.log(pageBody.children); // HTMLCollection of direct child elements // Each element is a full JavaScript object with useful properties console.log(pageBody.id); // The id attribute of <body> (often empty) console.log(pageBody.tagName); // Logs: BODY // The nodeType tells you what kind of node it is: // 1 = Element node (like <p>, <div>) // 3 = Text node (the actual text inside an element) // 9 = Document node (the root 'document' object) console.log(document.nodeType); // Logs: 9 console.log(pageBody.nodeType); // Logs: 1
HTML
BODY
HTMLCollection []
BODY
9
1
Selecting Elements — How to Grab What You Want to Change
Before you can change anything, you need to hold a reference to it in JavaScript — like picking up a puppet before you can move it. There are four main ways to do this, and once you understand the difference you'll always know which to reach for.
getElementById is the fastest and most direct — it's like calling someone by their unique passport number. IDs must be unique on a page, so this always returns exactly one element or null.
querySelector is the Swiss Army knife. It accepts any valid CSS selector ('#header', '.card', 'input[type="email"]') and returns the first match. If you already know CSS selectors, this feels instantly familiar.
querySelectorAll does the same but returns all matches as a NodeList — think of it as a list you can loop over. It looks like an array but it isn't quite one (more on that in the gotchas).
getElementsByClassName and getElementsByTagName still exist and you'll see them in older code, but querySelector and querySelectorAll cover everything they do and more, so stick to those for new work.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Selecting Elements Demo</title> </head> <body> <h1 id="page-title">Welcome to TheCodeForge</h1> <p class="article-summary">JavaScript is the language of the web.</p> <p class="article-summary">The DOM is your control panel.</p> <ul id="tech-list"> <li class="tech-item">HTML</li> <li class="tech-item">CSS</li> <li class="tech-item">JavaScript</li> </ul> <input type="email" id="user-email" placeholder="Enter your email"> <script> // --- getElementById --- // Fastest selector. Use it when you need one specific element by its unique ID. const pageTitle = document.getElementById('page-title'); console.log('getElementById:', pageTitle.textContent); // Output: getElementById: Welcome to TheCodeForge // --- querySelector (returns FIRST match only) --- // Accepts any CSS selector. Great for flexibility. const firstSummary = document.querySelector('.article-summary'); console.log('querySelector:', firstSummary.textContent); // Output: querySelector: JavaScript is the language of the web. // --- querySelectorAll (returns ALL matches as a NodeList) --- // Use this when you need to work with multiple elements at once. const allSummaries = document.querySelectorAll('.article-summary'); console.log('querySelectorAll count:', allSummaries.length); // Output: querySelectorAll count: 2 // Loop over a NodeList with forEach — just like an array allSummaries.forEach(function(summaryElement) { console.log(' -', summaryElement.textContent); }); // Output: // - JavaScript is the language of the web. // - The DOM is your control panel. // --- Complex CSS selectors work too --- // Select an <li> only if it's inside the element with id="tech-list" const firstTechItem = document.querySelector('#tech-list .tech-item'); console.log('Nested selector:', firstTechItem.textContent); // Output: Nested selector: HTML // Select by attribute const emailInput = document.querySelector('input[type="email"]'); console.log('Attribute selector:', emailInput.placeholder); // Output: Attribute selector: Enter your email // If an element doesn't exist, you get null — not an error const missingElement = document.getElementById('does-not-exist'); console.log('Missing element:', missingElement); // Output: Missing element: null </script> </body> </html>
querySelectorAll count: 2
- JavaScript is the language of the web.
- The DOM is your control panel.
Nested selector: HTML
Attribute selector: Enter your email
Missing element: null
Changing Content, Attributes, and Styles — Making Things Different
Selecting an element gets you the puppet. Now let's actually move it. There are three main things you'll want to change: the text/HTML inside an element, its HTML attributes (like src, href, class), and its visual styles.
Text and HTML content — textContent gives you plain text with all tags stripped out. It's the safe choice because it treats everything as literal text (so user-supplied content can't inject HTML). innerHTML lets you read and write raw HTML markup — powerful but handle with care since inserting untrusted strings via innerHTML is a common security hole.
Attributes — Every HTML attribute is accessible via getAttribute / setAttribute / removeAttribute. But for the most common ones, browsers also expose direct properties: element.src, element.href, element.id. Direct properties are faster to type and safer for values that need type conversion (like a checkbox's checked property, which is a boolean).
Styles — element.style.propertyName lets you set inline styles directly. CSS property names with hyphens become camelCase in JavaScript: background-color becomes backgroundColor. But for real-world UI changes, toggling CSS classes (classList.add, classList.remove, classList.toggle) is almost always better — it keeps your style logic in CSS where it belongs.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Changing Elements Demo</title> <style> /* We define visual states in CSS — not in JavaScript */ .highlight { background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 8px 12px; } .hidden { display: none; } .error-text { color: #dc3545; font-weight: bold; } </style> </head> <body> <h2 id="article-heading">Original Heading</h2> <p id="article-body">Original paragraph text.</p> <img id="profile-photo" src="old-photo.jpg" alt="Profile photo"> <a id="docs-link" href="https://old-site.com">Read the docs</a> <div id="status-banner" class="highlight">Status: Loading...</div> <script> // ── CHANGING TEXT CONTENT ────────────────────────────────────── const articleHeading = document.getElementById('article-heading'); // textContent replaces ALL text inside the element (strips existing HTML) articleHeading.textContent = 'DOM Manipulation — Updated by JavaScript'; console.log(articleHeading.textContent); // Output: DOM Manipulation — Updated by JavaScript // innerHTML lets you inject HTML markup // Use this when you genuinely need to insert tags, not for plain text const articleBody = document.getElementById('article-body'); articleBody.innerHTML = 'JavaScript is <strong>powerful</strong> and <em>fun</em>.'; // The browser now renders bold and italic text inside the paragraph // ── CHANGING ATTRIBUTES ──────────────────────────────────────── const profilePhoto = document.getElementById('profile-photo'); // setAttribute works for any HTML attribute profilePhoto.setAttribute('src', 'new-photo.jpg'); profilePhoto.setAttribute('alt', 'Updated profile photo'); // Direct property access is cleaner for well-known attributes const docsLink = document.getElementById('docs-link'); docsLink.href = 'https://developer.mozilla.org'; // Direct property — no need for setAttribute docsLink.textContent = 'MDN Web Docs'; // Update link text too // getAttribute reads back the current attribute value console.log(docsLink.getAttribute('href')); // Output: https://developer.mozilla.org // ── CHANGING STYLES DIRECTLY (for dynamic, computed values) ──── const statusBanner = document.getElementById('status-banner'); // Inline style: CSS property names become camelCase in JavaScript statusBanner.style.fontSize = '18px'; // font-size → fontSize statusBanner.style.borderRadius = '4px'; // border-radius → borderRadius // ── CHANGING STYLES VIA CSS CLASSES (the right way for UI states) // classList.add — adds a class without affecting existing ones statusBanner.classList.add('highlight'); // Already has it, no duplicate added // classList.remove — removes a class statusBanner.classList.remove('highlight'); // classList.toggle — adds if missing, removes if present. Perfect for on/off states. statusBanner.classList.toggle('highlight'); // Adds it back statusBanner.classList.toggle('highlight'); // Removes it again // classList.contains — check if a class is present (returns true/false) console.log(statusBanner.classList.contains('highlight')); // Output: false (we just toggled it off) // Now show the correct state statusBanner.textContent = 'Status: Ready'; statusBanner.classList.add('highlight'); console.log(statusBanner.className); // Output: highlight </script> </body> </html>
https://developer.mozilla.org
false
highlight
Creating Elements and Reacting to Events — Building and Responding
So far we've been modifying elements that already exist in the HTML. But you can also create entirely new elements in JavaScript and add them to the page — this is how comment sections, to-do lists, and notification toasts work.
The pattern is always the same three steps: create, configure, attach. You create a new element with document.createElement('tagName'), configure it by setting its properties, and then attach it to the DOM with appendChild or insertBefore.
But a page that just changes on load isn't interactive. To react to users, you need event listeners. An event listener is a function you register on an element that says 'when X happens to this element, run this code'. The X could be a click, a keypress, a form submission, a mouse hover — browsers fire dozens of different event types.
You register listeners with element.addEventListener('eventType', handlerFunction). The handler function automatically receives an event object as its first argument — this object contains details about what happened (which key was pressed, where the mouse was, which element was clicked). You'll use event.preventDefault() frequently to stop default browser behaviours like form submissions navigating to a new page.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Create Elements and Events Demo</title> <style> body { font-family: sans-serif; max-width: 500px; margin: 40px auto; padding: 0 20px; } .task-item { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; margin: 6px 0; background: #f8f9fa; border-radius: 4px; border: 1px solid #dee2e6; } .task-item.completed span { text-decoration: line-through; color: #6c757d; } .delete-btn { background: #dc3545; color: white; border: none; border-radius: 3px; padding: 2px 8px; cursor: pointer; } </style> </head> <body> <h2>My Task List</h2> <form id="add-task-form"> <input type="text" id="task-input" placeholder="Enter a new task..." style="padding: 8px; width: 70%; font-size: 14px;" > <button type="submit" style="padding: 8px 16px; margin-left: 6px;">Add</button> </form> <ul id="task-list" style="list-style: none; padding: 0; margin-top: 20px;"></ul> <script> // Grab references to the persistent elements we need repeatedly const addTaskForm = document.getElementById('add-task-form'); const taskInput = document.getElementById('task-input'); const taskList = document.getElementById('task-list'); // ── LISTEN FOR FORM SUBMISSION ───────────────────────────────── addTaskForm.addEventListener('submit', function(submitEvent) { // Prevent the default behaviour: forms normally navigate to a new URL on submit submitEvent.preventDefault(); // Read the current value of the text input and trim whitespace from both ends const taskText = taskInput.value.trim(); // Don't add empty tasks if (taskText === '') { alert('Please enter a task before adding.'); return; // Exit the function early } // ── CREATE → CONFIGURE → ATTACH ─────────────────────────────── // Step 1: CREATE — make a new <li> element (it's not on the page yet) const newTaskItem = document.createElement('li'); newTaskItem.className = 'task-item'; // Apply our CSS class // Step 2: CONFIGURE — build the inner content using innerHTML here // (The text comes from our own form, not an external source, so it's safer, // but we still escape it properly by setting the span's textContent separately) const taskLabel = document.createElement('span'); taskLabel.textContent = taskText; // Safe: textContent never interprets HTML tags const deleteButton = document.createElement('button'); deleteButton.textContent = 'Delete'; deleteButton.className = 'delete-btn'; // Attach the label and button to the new list item newTaskItem.appendChild(taskLabel); newTaskItem.appendChild(deleteButton); // Step 3: ATTACH — add the new <li> into the <ul> on the page taskList.appendChild(newTaskItem); // Clear the input so the user can type the next task immediately taskInput.value = ''; taskInput.focus(); // Put cursor back in the input // ── ADD EVENTS TO THE NEW ELEMENTS ──────────────────────────── // Click the task label to toggle it as completed taskLabel.addEventListener('click', function() { // Toggle the 'completed' class on the parent <li> newTaskItem.classList.toggle('completed'); }); // Click the delete button to remove the task entirely deleteButton.addEventListener('click', function() { // .remove() detaches the element from the DOM and the browser stops showing it newTaskItem.remove(); console.log('Deleted task:', taskText); }); console.log('Task added:', taskText); }); // ── LISTEN FOR KEYBOARD INPUT (live character count example) ─── taskInput.addEventListener('input', function(inputEvent) { // The 'input' event fires on every keystroke const currentLength = taskInput.value.length; // In a real app you'd update a character counter element here if (currentLength > 50) { taskInput.style.borderColor = '#dc3545'; // Red border warning } else { taskInput.style.borderColor = ''; // Reset to default } }); </script> </body> </html>
Task added: Read a chapter of a book
Deleted task: Buy groceries
| Method | Returns | Accepts CSS Selectors? | Live collection? | Best Used For |
|---|---|---|---|---|
| getElementById('id') | Single element or null | No — ID only | No | Selecting one unique element by its ID — fastest option |
| querySelector('selector') | First matching element or null | Yes — any CSS selector | No | Selecting one element with flexible CSS syntax |
| querySelectorAll('selector') | Static NodeList (all matches) | Yes — any CSS selector | No (static snapshot) | Looping over multiple elements — use forEach on the result |
| getElementsByClassName('cls') | Live HTMLCollection | No — class name only | Yes (auto-updates) | Older codebases; prefer querySelectorAll in new code |
| getElementsByTagName('tag') | Live HTMLCollection | No — tag name only | Yes (auto-updates) | Older codebases; prefer querySelectorAll in new code |
🎯 Key Takeaways
- The DOM is a live, in-memory tree the browser builds from HTML — changing a node immediately updates what the user sees, with no page reload required.
- Always use
querySelectorandquerySelectorAllfor new code — they accept any CSS selector, making them far more flexible than the oldergetElement*methods. textContentis safe for plain text (prevents HTML injection).innerHTMLis powerful but dangerous with untrusted input — never use it with data from users or external APIs without sanitizing it first.- The three-step pattern for adding new content is always: createElement → configure properties/content → appendChild. Master this loop and you can build any dynamic UI.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Running DOM code before the page loads — Symptom:
Cannot read properties of nullon the very first line of your script, even though the element clearly exists in the HTML — Fix: Place yourtag just before the closingtag, or wrap your code indocument.addEventListener('DOMContentLoaded', function() { ... }). The browser builds the DOM top-to-bottom, so a script inruns before anyelements exist. - ✕Mistake 2: Treating a NodeList like an Array — Symptom:
allItems.map is not a functionorallItems.filter is not a functionwhen trying to chain array methods on aquerySelectorAllresult — Fix: Convert it first withArray.from(allItems).map(...)or spread it:[...allItems].map(...). A NodeList hasforEachbut notmap,filter, orreduce. Converting it gives you all the array methods you expect. - ✕Mistake 3: Forgetting that event listeners on removed elements can leak memory — Symptom: App feels sluggish over time, especially in a single-page app where elements are created and destroyed frequently — Fix: When you remove an element with complex listeners, remove the listeners first with
element.removeEventListener('click', handlerFunction), or redesign to use event delegation on a stable parent container so there's only ever one listener to manage.
Interview Questions on This Topic
- QWhat is the difference between `textContent`, `innerText`, and `innerHTML`? When would you use each one?
- QWhat is event delegation and why is it preferable to attaching individual event listeners to each item in a dynamic list?
- QIf `document.getElementById('my-button')` returns null at runtime even though the element is in your HTML, what are the two most likely causes and how would you debug each one?
Frequently Asked Questions
What is the DOM in JavaScript and why do we need it?
The DOM (Document Object Model) is a live, tree-shaped representation of your HTML page that the browser builds in memory when it loads a file. We need it because HTML alone is static — it describes a fixed layout. The DOM gives JavaScript a structured API to read and modify any part of that layout at any time, which is what makes interactive web pages possible.
What is the difference between querySelector and getElementById?
getElementById only searches by ID and is marginally faster, but querySelector accepts any valid CSS selector (IDs, classes, attributes, pseudo-classes, combinations) making it far more flexible. For new code, most developers default to querySelector/querySelectorAll for everything and only reach for getElementById when maximum performance in a tight loop is a concern.
Why does my JavaScript say 'Cannot read properties of null' when I try to change an element?
This means your selector returned null — the element wasn't found. The two most common reasons are: (1) your script runs before the browser has parsed that part of the HTML, which you fix by moving the tag to the bottom of or using DOMContentLoaded; and (2) there's a typo in the ID or class name you're selecting, which you fix by double-checking the element in DevTools > Elements.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.