JAVASCRIPT / THE BROWSER: DOM, EVENTS, AND STORAGE
Changing text, HTML, and attributes safely
Update text, markup, and attributes on DOM elements without creating an XSS hole or silently destroying the children and listeners you already have.
What you will learn
- Write anything that came from a user with textContent, not innerHTML
- Read current form state from .value; getAttribute('value') only shows the default
- Replace innerHTML += with append or insertAdjacentHTML to keep existing nodes alive
- Toggle boolean attributes through the property: el.disabled = false, not 'false'
Understanding Changing text, HTML, and attributes safely
An element gives you three different doors for changing what it shows, and they differ on purpose. Assigning to textContent means "remove every child and put one text node here holding exactly these characters", so a < in the string stays a less-than character. Assigning to innerHTML means "remove every child and hand this string to the HTML parser", so the same < starts a tag. setAttribute and the reflected properties touch neither children nor text: they change data stored on the element itself.
Attributes are the strings that live in markup; properties are live values on the element object, and the two are linked without being identical. On an input, the value attribute is the initial value while .value is what sits in the field right now, so getAttribute('value') keeps reporting 'draft' long after the user typed something else. On a link, the href attribute is the literal text you wrote, while .href is that text resolved against the page URL. Boolean attributes such as disabled, checked and hidden are presence-based so their value is ignored, and data-* attributes are easiest to reach through dataset, where every value arrives as a string.
innerHTML is where injection lives, because the parser cannot tell your markup from somebody's name. A <script> pasted through innerHTML does not execute, which is exactly why people wrongly conclude the API is safe: the parser still builds other elements and wires their inline handlers, so <img src=x onerror=...> fires immediately. Interpolating a value into an attribute leaks the same way, since one quote character in the data closes the attribute and lets the rest become new attributes. Even ignoring security, innerHTML += serializes the subtree to a string and reparses it, so every child is replaced by a fresh clone that has lost its listeners, focus, scroll position and typed input.
const card = document.createElement('div');
card.innerHTML = '<b>Ada</b> Lovelace';
console.log(card.textContent);
console.log(card.innerHTML);
// the same characters written as text stay characters
const fromUser = '<img src=x onerror="steal()">';
card.textContent = fromUser;
console.log(card.innerHTML);
console.log(card.querySelector('img'));
// attribute holds the default, property holds current state
const input = document.createElement('input');
input.setAttribute('value', 'draft');
input.value = 'edited';
console.log(input.getAttribute('value'), '|', input.value);Whether a string is treated as plain characters or as markup is decided by which property you assign to, and that single choice controls both security and whether existing nodes survive.
Worked examples
Boolean attributes ignore their value
Shows why disabled="false" still disables a button and how the property write removes the attribute.
const btn = document.createElement('button');
btn.setAttribute('disabled', 'false');
console.log('property:', btn.disabled);
console.log('attribute:', JSON.stringify(btn.getAttribute('disabled')));
btn.disabled = false;
console.log('after property write:', btn.hasAttribute('disabled'), JSON.stringify(btn.getAttribute('disabled')));Example explained
Line 1setAttribute('disabled', 'false') adds the attribute, and for a boolean attribute only its presence is read, so .disabled is true.
Line 2getAttribute returns the literal string "false": the value was stored faithfully, it simply carries no meaning.
Line 3Assigning false to the reflected property removes the attribute completely, which is why hasAttribute is false and getAttribute is null.
innerHTML += replaces children with clones
Demonstrates that appending markup through += reparses the element and throws away existing nodes and their listeners.
const list = document.createElement('ul');
const first = document.createElement('li');
first.textContent = 'one';
first.addEventListener('click', () => console.log('clicked'));
list.append(first);
list.innerHTML += '<li>two</li>';
console.log(list.children.length);
console.log(list.children[0] === first);
first.click();
list.children[0].click();
console.log(list.innerHTML);Example explained
Line 1list.innerHTML += ... reads the subtree as a string, concatenates, then assigns, which drops all children and parses the whole string again.
Line 2list.children[0] === first is false because the visible first item is now a newly parsed node, not the one you kept a reference to.
Line 3first.click() still logs, proving the original node survives only in your variable, detached from the list.
Line 4list.children[0].click() logs nothing: the replacement node was built by the parser and no listener was ever attached to it.
A quote in the data becomes syntax
Compares interpolating an untrusted value into an innerHTML attribute against setting the same value with setAttribute.
const name = 'x" onmouseover="steal()';
const risky = document.createElement('div');
risky.innerHTML = `<span title="${name}">hi</span>`;
const parsed = risky.firstElementChild;
console.log(parsed.getAttribute('title'));
console.log(parsed.getAttribute('onmouseover'));
const safe = document.createElement('span');
safe.textContent = 'hi';
safe.setAttribute('title', name);
console.log(safe.getAttribute('onmouseover'));
console.log(safe.outerHTML);Example explained
Line 1The raw quote inside name closed the title attribute, so the parser read the remainder as a second attribute and registered it as a real event handler.
Line 2getAttribute('title') is only 'x' because the rest of the user string was promoted from data to structure.
Line 3setAttribute stores the entire string as one value, so no onmouseover attribute exists at all.
Line 4outerHTML shows " because escaping happens when the DOM is serialized back to text, meaning the quote never acted as syntax.
Important notes
setAttribute is not automatically safe. Untrusted values in href or src can be javascript: or data: URLs, and any on* attribute executes its value, so validate the URL scheme and never build attribute names from input.
textContent and innerText are not interchangeable: innerText works on rendered text, so it skips display:none content, collapses whitespace, and forces layout, while textContent gives you the raw characters at predictable cost.
Common mistakes
Concluding innerHTML is safe because a pasted <script> tag did nothing: the same field containing <img src=x onerror="fetch('/steal?c='+document.cookie)"> runs code with the visitor's session.
Writing setAttribute('disabled', 'false') or checked="false" to turn a control back on: the attribute is still present, so the control stays disabled or checked and the bug looks like a framework problem.
Refreshing a list with container.innerHTML += row: listeners on the rows that were already there stop firing, because every child was reparsed into a different node.
Try it yourself
Change, predict, then run
In a blank page, add an input and two divs, then on every input event write the typed value into the first div with textContent and into the second with innerHTML. Type <img src=x onerror="alert(1)"> and compare what each div actually contains in the Elements panel.
Open the JavaScript workspaceCheck your understanding
A developer pastes <script>steal()</script> into a comment field, sees nothing happen after box.innerHTML = comment, and decides innerHTML is safe for comments. Why is that conclusion wrong?
- The script did execute, but errors thrown by innerHTML are swallowed silently.
- innerHTML escaped the angle brackets, so no element was created and no other markup could ever run.
- innerHTML creates a real script element that the parser refuses to execute, while other markup such as <img src=x onerror=...> still gets its inline handler wired up and fires.
- Nothing ran because the div was not in the document yet; once it is appended, both script tags and inline handlers execute.
Show answer
Script elements inserted through innerHTML are flagged as already started, so a <script> payload is a misleading test; the parser still builds every other element and registers its inline event handlers, and an img with a broken src fires onerror right away. Option 1 is tempting because textContent really does escape < and >, but innerHTML never escapes anything: parsing the string as markup is its entire purpose.