Reading and updating content safely

textContent versus innerHTML versus form properties, attribute and class handling, and the script-injection bug that comes with the powerful option.

Choosing the right property

PropertyGives youUse it for
textContentraw text; markup is shown literallyanything a user typed
innerTextrendered text; respects CSS, forces layoutmeasuring what is visible
innerHTMLparsed markup, serialised back on readtrusted templates only
valuethe current form control valueinput, textarea, select
datasetthe data-* attributes as an objectstate shared with CSS
title.textContent = user.name;            // safe: rendered as plain text
badge.dataset.state = 'ready';            // writes <span data-state="ready">
output.value = String(total);             // form controls use value, not textContent
note.insertAdjacentText('beforeend', ' ok');

// reading back
badge.dataset.state;                      // 'ready'
select.options[select.selectedIndex].text;
⚠️
Assigning untrusted input to innerHTML turns a comment box into script execution. Use textContent, or sanitise the string with a maintained library first; building HTML by concatenating user data is the same bug with extra steps.

Attributes, classes and inline styles

link.setAttribute('href', '/docs');
link.getAttribute('aria-expanded');     // null when the attribute is absent
link.hasAttribute('hidden');
link.removeAttribute('hidden');

// toggle takes a second argument that forces the state
panel.classList.toggle('is-open', isOpen);
panel.classList.replace('old', 'new');
panel.classList.contains('is-open');

// styles: prefer classes, use custom properties for values computed at runtime
panel.style.setProperty('--hue', hue + 'deg');
  • Attributes are always strings; DOM properties are typed. input.value is a string, while input.checked is a boolean.
  • classList methods are safer than assigning className, which silently wipes every other class on the element.
  • Only aria-* and data-* attributes accept names you invent; other made-up attributes are invalid HTML.
  • Reading a layout property such as offsetHeight forces the browser to compute styles, so do not interleave reads and writes in a tight loop.

Running your code at the right time

function boot() {
  const list = document.querySelector('#todos');   // null if this ran too early
  if (!list) return;
  list.addEventListener('click', onClick);
}

if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', boot);   // HTML parsed
} else {
  boot();          // already parsed: the script was deferred or loaded late
}
  • DOMContentLoaded fires when the HTML is parsed, without waiting for images or fonts.
  • load on window waits for every subresource, which is later and usually unnecessary.
  • A module script is deferred by default and executes in document order; a classic script in the head executes before the body exists.
  • Calling an initialiser twice attaches every listener twice, so guard the entry point against a repeated call.

FAQ

textContent or innerText?
textContent is fast and returns every text node verbatim, including hidden ones. innerText reflects what is rendered, respects CSS and forces a layout pass, so reach for it only when you need the visible text.
How do I update one row in a long list?
Change that node. Rewriting a parent with innerHTML destroys focus, scroll position and the event listeners attached to its children, and it recreates every element.

Selecting elements Creating, removing and traversing

Last refreshed 2026-09-18.