Changing Text: textContent, innerHTML and innerText
Three properties change what an element says, and choosing correctly between them is the most important security decision a beginner makes without realising it.
textContent sets and reads plain text. Whatever you assign is treated as text, so a value containing <b>hi</b> appears on the page as those exact characters rather than as bold text. That is precisely what you want for anything a user typed or a server sent, and it is also the fastest of the three because the browser has nothing to parse.
innerHTML parses what you assign as HTML and builds real elements from it. That is powerful, and it is exactly how a malicious string becomes running code on your page. Browsers do not execute a plain script tag inserted this way, but other constructions do run — an image tag with an onerror handler is the standard example — so treat the whole approach as unsafe for untrusted input. Use innerHTML only with markup you wrote yourself.
innerText is a third option that reflects what is actually visible: it respects CSS, skipping hidden elements and collapsing whitespace the way the page renders it. That makes it slower, because the browser may have to work out layout before it can answer. Use textContent as your default and reach for innerText only when you specifically need the visible text, such as copying what a user can see.
const box = document.querySelector('#box');
// textContent: everything is treated as text
box.textContent = 'Total: <b>500</b>';
console.log(box.textContent); // shown on the page exactly as typed
// innerHTML: parsed as markup, so 500 really becomes bold
box.innerHTML = 'Total: <b>500</b>';
// The unsafe pattern - never do this with anything a user typed
const typed = document.querySelector('#comment').value;
// box.innerHTML = typed; // an injection point
box.textContent = typed; // safe, and faster
// Reading them back gives different answers
box.innerHTML = 'Hello <em>there</em>';
console.log(box.textContent); // 'Hello there'
console.log(box.innerHTML); // 'Hello <em>there</em>' - A useful default to hold on to: if the value came from outside your code — a form, an API, a URL — it goes in with
textContent. If you need real markup around it, build the elements yourself and put the untrusted value into thetextContentof one of them.
Attributes, Properties and data- Attributes
An HTML attribute is what you wrote in the markup. A DOM property is what the element object currently holds. They start out matching and then drift apart as the user interacts with the page, and knowing that explains a bug which confuses nearly everybody the first time.
Type into a text box and read input.value: you get what the user typed. Read input.getAttribute('value') and you get what the HTML originally said, which is usually nothing at all. So for form fields, always use the property — input.value, checkbox.checked, select.value. getAttribute reads the markup, not the current state.
setAttribute, getAttribute, removeAttribute and hasAttribute handle attributes when you genuinely want them. Boolean attributes such as disabled, checked and required work by presence rather than value, so setAttribute('disabled', 'false') still disables the button — the attribute is there, and that is all the browser looks at. Set the property instead: button.disabled = false.
For your own data, use data- attributes. A button written as data-id="42" in the markup is readable in JavaScript as button.dataset.id, which gives the string '42'. It is always a string, so convert when you need a number. This is the standard way to attach a record's id to a generated element so that a click handler can tell which row it belongs to, and it keeps the connection in the DOM rather than in a fragile parallel array.
const input = document.querySelector('#email');
// Property = current state; attribute = the original markup
input.value = 'typed@example.com';
console.log(input.value); // 'typed@example.com'
console.log(input.getAttribute('value')); // whatever the HTML said
// Attributes
const link = document.querySelector('a');
link.setAttribute('target', '_blank');
console.log(link.getAttribute('href'));
console.log(link.hasAttribute('target')); // true
link.removeAttribute('target');
// Boolean attributes work by presence, not by value
const btn = document.querySelector('button');
btn.setAttribute('disabled', 'false'); // STILL disabled
btn.disabled = false; // this is what enables it
// data- attributes carry your own values
// markup: <button data-id="42" data-role="delete">Delete</button>
console.log(btn.dataset.id); // '42' - always a string
console.log(Number(btn.dataset.id)); // 42
console.log(btn.dataset.role); // 'delete' - A hyphenated attribute becomes camelCase in
dataset:data-user-idis read asel.dataset.userId. Getting that conversion wrong givesundefinedrather than an error, so check the spelling first when a dataset value comes back empty.
Styling: style versus classList
There are two ways to change how an element looks, and one of them is the right answer nearly all of the time.
element.style.color = 'red' writes an inline style directly onto the element. It works, and it carries three costs. Inline styles have very high specificity, so they override your stylesheet and are awkward to undo. Property names are camelCase — backgroundColor, not background-color — which is one more thing to get wrong. And your visual design ends up scattered through JavaScript files where nobody working on the CSS will ever find it.
classList is the better tool. Its add, remove, toggle and contains methods let JavaScript decide the state while CSS decides the appearance, which is the separation the two languages were designed for. toggle is particularly neat: it adds the class when absent, removes it when present, and returns a boolean telling you which just happened — so a theme switch or an accordion becomes a single line.
Keep style for values genuinely computed at runtime that could not have been written in a stylesheet: a progress bar's width from a percentage, an element's position during a drag. For anything a CSS rule also needs, custom properties bridge the two neatly — set el.style.setProperty('--progress', '60%') from JavaScript and let the stylesheet do the rest with var(--progress).
const box = document.querySelector('#box');
// Inline style - note the camelCase property names
box.style.backgroundColor = '#d1039e';
box.style.borderRadius = '8px';
box.style.display = 'none';
// classList - the better default
box.classList.add('card', 'highlighted');
box.classList.remove('highlighted');
console.log(box.classList.contains('card')); // true
// toggle tells you what it just did
const isOpen = box.classList.toggle('open');
console.log(isOpen); // true the first time, false the next
// A genuinely computed value belongs in style
const percent = 63;
const bar = document.querySelector('.progress-fill');
bar.style.width = percent + '%';
// Or hand the value to CSS through a custom property
bar.style.setProperty('--progress', percent + '%'); element.styleonly reads back inline styles — it cannot tell you what a stylesheet applied. To read the value actually in effect, usegetComputedStyle(element).color, which returns the resolved value the browser is using.
Creating, Inserting and Removing Elements
Building an element takes three steps: create it, fill it, insert it. Until that third step it exists only in memory and nothing appears on screen — which is genuinely useful, because it means you can do all your work before the browser has to draw anything.
document.createElement('li') creates. textContent, className and dataset fill it. Then parent.append(child) inserts it at the end, parent.prepend(child) at the start, and element.before(x) or element.after(x) place it relative to an existing sibling.
append accepts several children in one call and accepts plain strings as well as elements, neither of which the older appendChild does — appendChild takes exactly one node and nothing else. Both work; append is simply more convenient and reads better.
Removing is element.remove(), which needs no reference to the parent at all. The older parent.removeChild(child) still works and is more to type. Note that removing an element from the page does not destroy your variable pointing at it — the object is still there, detached — so you can remove an element, hold on to it, and put it back later with the same reference.
const list = document.querySelector('#tasks');
// 1. create
const item = document.createElement('li');
// 2. fill
item.textContent = 'Revise array methods';
item.className = 'task';
item.dataset.id = '7';
// 3. insert - nothing is visible until this line runs
list.append(item);
// Controlling position
list.prepend(document.createElement('li')); // at the start
item.after(document.createElement('li')); // immediately after item
// append takes several things at once, including plain text
const row = document.createElement('div');
const label = document.createElement('strong');
label.textContent = 'Total:';
row.append(label, ' ', '500');
list.append(row);
// Removing
item.remove();
// The element still exists in your variable, so it can go back
list.append(item); element.cloneNode(true)copies an element and everything inside it, which is how you stamp out repeated rows from a template. Thetruemeans deep — leave it out and you get the element without its contents.
Building Lists Without Making the Page Crawl
Every time you insert something into the live page, the browser may have to recalculate layout. Do that once and the cost is invisible. Do it two hundred times inside a loop and the page visibly stutters — this is the single most common cause of a sluggish interface built by a beginner.
There are two standard fixes. Build everything inside a DocumentFragment, which is a lightweight container that is not part of the page, and append the fragment once at the end — appending a fragment moves its children in and leaves the fragment empty. Or build one HTML string and assign it to innerHTML a single time.
The fragment approach is the safer of the two, because it never parses a string as markup and because you keep real element references as you go, so you can attach behaviour or store data on each row while you build it. If you do choose the string approach, remember the first section of this lesson: it is safe only for data you control, and the moment any value came from a user you must either escape it or switch to fragments and textContent.
The demo below builds a list from an array and lets you add to and delete from it. Three things in it are worth copying into your own code: the fragment is appended exactly once; each row's id lives in a data- attribute rather than in a parallel array; and the delete handler uses closest to find the row it belongs to. That combination is the shape of list code in every framework you will meet later.
const students = ['Ananya', 'Rahul', 'Priya'];
const list = document.querySelector('#tasks');
// Slow: touches the live page once per item
for (const name of students) {
const li = document.createElement('li');
li.textContent = name;
list.append(li); // a layout opportunity on every pass
}
// Better: build off-page, then insert once
const fragment = document.createDocumentFragment();
for (const name of students) {
const li = document.createElement('li');
li.textContent = name;
fragment.append(li);
}
list.append(fragment); // a single insertion
// Also fine, but only for data you control
list.innerHTML = students.map(n => `<li>${n}</li>`).join(''); HTML
<div class="dom-demo">
<h3>Task list</h3>
<input type="text" id="taskInput" placeholder="New task">
<button id="addBtn">Add</button>
<ul id="taskList"></ul>
<p id="count"></p>
</div> CSS
.dom-demo { padding: 20px; background: #f0f0f0; border-radius: 8px; font-family: system-ui, sans-serif; }
input { padding: 10px; border: 1px solid #ddd; border-radius: 4px; width: 220px; }
button { padding: 10px 16px; background: #d1039e; color: white; border: none; border-radius: 5px; cursor: pointer; margin-left: 6px; }
ul { list-style: none; padding: 0; margin: 15px 0 0; }
li { display: flex; justify-content: space-between; align-items: center; background: white; padding: 10px 12px; border-radius: 5px; margin-bottom: 6px; border-left: 4px solid #d1039e; }
li button.del { background: #666; padding: 5px 10px; font-size: 0.85rem; margin: 0; }
#count { color: #555; font-size: 0.9rem; } JavaScript
let tasks = [
{ id: 1, text: 'Revise array methods' },
{ id: 2, text: 'Finish the DOM lesson' }
];
let nextId = 3;
const list = document.getElementById('taskList');
const input = document.getElementById('taskInput');
const count = document.getElementById('count');
function render() {
const fragment = document.createDocumentFragment();
for (const task of tasks) {
const li = document.createElement('li');
li.dataset.id = task.id; // the id travels with the element
const span = document.createElement('span');
span.textContent = task.text; // textContent, so typed tags stay as text
const del = document.createElement('button');
del.textContent = 'Delete';
del.className = 'del';
li.append(span, del);
fragment.append(li);
}
list.innerHTML = ''; // clear once
list.append(fragment); // insert once
count.textContent = tasks.length + ' task(s)';
}
document.getElementById('addBtn').addEventListener('click', function () {
const text = input.value.trim();
if (text === '') return;
tasks.push({ id: nextId, text: text });
nextId = nextId + 1;
input.value = '';
render();
});
// One listener on the parent handles every delete button, now and later
list.addEventListener('click', function (event) {
if (!event.target.classList.contains('del')) return;
const id = Number(event.target.closest('li').dataset.id);
tasks = tasks.filter(function (t) { return t.id !== id; });
render();
});
render(); - Type
<b>bold</b>into the box and add it. Because the demo usestextContentfor the task text, the tags appear as ordinary characters instead of turning the entry bold. Change that one line toinnerHTMLand you have built an injection point into your own page.
The Manipulation Bugs Worth Knowing
Reading a property off null. This is Lesson 15's problem arriving in Lesson 16's code. Cannot read properties of null (reading 'textContent') always means the selector found nothing — a typo, or a script that ran before the element existed. Fix the selection, not the line that threw.
Forgetting that value is always a string. Reading a number input still gives you text, so qty.value + 1 produces '51' rather than 6. Convert at the moment you read the field, with Number(input.value), and every line after that can be trusted.
Re-rendering the whole list on every keystroke. Rebuilding a hundred rows because one character changed is wasteful, and it also destroys focus and scroll position, so the input box loses the cursor mid-word. Render when the data actually changes, not on every event.
textContentfor anything a user typed;innerHTMLonly for markup you wroteinput.value, nevergetAttribute('value'), for the current contents of a fieldclassList.togglefor state; inlinestyleonly for computed values- Create, fill, then insert — nothing appears on screen until the insert
- Insert once from a
DocumentFragment, not once per item inside a loop data-attributes carry an id from your data to the element and back againelement.remove()detaches the element; your variable still points at it- Boolean attributes: set
btn.disabled = false, notsetAttribute('disabled', 'false')
- When a change does not appear on screen, check three things in order: did the selector find the element, did the code actually run, and did you insert the element you created. Those three account for almost every "nothing happens" report.
