textContent vs innerHTML vs innerText in JavaScript
textContent vs innerHTML vs innerText in JavaScript is not three spellings for "put words in a div." textContent writes text. innerHTML asks the browser to parse HTML. innerText reads the text as it is rendered on the page. Mix those contracts up and the code often still runs, which is how a comment field quietly becomes an HTML parser.
The short rule: default to textContent, use innerHTML only for trusted markup, and reach for innerText only when rendered visibility matters.
The naive approach
A beginner often reaches for innerHTML because the name sounds like "the stuff inside this element."
const submittedMessage = "Ship <strong>today</strong>";
const preview = document.querySelector("#message-preview");
preview.innerHTML = submittedMessage;
The result is not plain text. The browser creates a real <strong> element inside the preview.
<p id="message-preview">
Ship <strong>today</strong>
</p>
That might be fine if the string is trusted markup you wrote yourself. It is a bad default for user input, API data, comments, profile names, search results, or anything else that should be treated as data. For plain text, write plain text:
const submittedMessage = "Ship <strong>today</strong>";
const preview = document.querySelector("#message-preview");
preview.textContent = submittedMessage;
Now the angle brackets are shown as characters. No new element is created.
The quick comparison
| Property | Reads | Writes | Best use |
|---|---|---|---|
textContent | All text inside the node and its descendants | Replaces children with one text node | Safe plain-text updates |
innerHTML | HTML markup inside the element | Parses a string as HTML and replaces children | Trusted HTML templates |
innerText | Rendered, human-visible text | Replaces children with rendered-style text | Copying or comparing visible text |
That table hides one nasty detail: all three can replace child nodes when you assign to them. If an element contains icons, spans, buttons, or listeners on child nodes, assigning to the parent can wipe out more structure than you intended.
textContent treats text as text
Use textContent when the value should appear literally.
const status = document.querySelector("#save-status");
status.textContent = "Profile saved";
It also works for data that contains characters with meaning in HTML:
const comment = "<img src=x onerror=alert(1)>";
const commentBody = document.querySelector("#comment-body");
commentBody.textContent = comment;
The browser displays the characters. It does not create an image, does not attach an onerror handler, and does not treat the string as markup. Lovely. Boring. Exactly what a comment renderer should do.
Reading textContent returns the concatenated text of the node and its descendants:
const card = document.querySelector("#profile-card");
console.log(card.textContent);
It does not care whether text is hidden with CSS. It reads the DOM text, not the visual result.
textContent replaces the children
This part is easy to miss:
<button id="cart-button">
<span class="icon" aria-hidden="true">+</span>
<span class="label">Add</span>
</button>
If you update the whole button, you remove both spans:
const cartButton = document.querySelector("#cart-button");
cartButton.textContent = "Added";
The button becomes:
<button id="cart-button">Added</button>
That is not a text update. That is a small demolition job with a friendly property name. If only the label should change, select the label:
const label = document.querySelector("#cart-button .label");
label.textContent = "Added";
The structure survives.
innerHTML is for trusted markup
innerHTML is useful when the string is supposed to be HTML.
const emptyState = document.querySelector("#empty-state");
emptyState.innerHTML = `
<h2>No invoices yet</h2>
<p>Create your first invoice to see it here.</p>
`;
That code is intentionally asking the browser to parse two elements. The problem starts when you combine template markup with untrusted text:
const userName = "<img src=x onerror=alert(1)>";
const profile = document.querySelector("#profile");
profile.innerHTML = `<h2>${userName}</h2>`;
The template literal did not make the string safe. It only placed the value into a larger string. If that larger string goes into innerHTML, the browser still parses it as HTML. For more on that trap, see Template Literals.
Build the element structure yourself when the text is data:
const userName = "<img src=x onerror=alert(1)>";
const profile = document.querySelector("#profile");
const heading = document.createElement("h2");
heading.textContent = userName;
profile.replaceChildren(heading);
That is a few more lines, yes. It also makes the boundary honest: markup is markup, text is text.
Avoid innerHTML += for updates
This looks convenient:
log.innerHTML += `<li>${message}</li>`;
It is doing more than appending a list item. The browser reads the existing HTML, concatenates a new string, reparses the full result, and replaces the children. Any state stored in old DOM nodes can disappear with it. Any user-controlled message is also being treated as markup.
Prefer node creation for repeated updates:
const item = document.createElement("li");
item.textContent = message;
log.append(item);
Now the new item is the only new node, and the message is still text.
innerText is about rendered text
innerText is closest to what a user can see and select. It pays attention to rendering, including hidden elements and line breaks. That makes it different from textContent.
<article id="notice">
<h2>Billing</h2>
<p class="visually-hidden">Internal note</p>
<p>Payment due soon</p>
</article>
const notice = document.querySelector("#notice");
console.log(notice.textContent);
console.log(notice.innerText);
textContent reads the text in the DOM. innerText reads the rendered text. If CSS hides the internal note, innerText skips it.
That sounds handy, but it has a cost. Because innerText depends on layout and computed styles, reading it can force the browser to make sure layout information is current. In normal app code, that is usually not the property you want for data. Use it when you genuinely need visible text, such as copying a rendered receipt or checking what a user-facing region displays.
Use the right property by intent
Most decisions come down to one question: should the string be parsed as markup?
| Situation | Use |
|---|---|
| Show a username, comment, label, count, or API string | textContent |
| Clear an element safely | replaceChildren() or textContent = "" |
| Insert trusted static markup | innerHTML |
| Render user data inside markup | Create elements, then assign user data with textContent |
| Read all DOM text, including hidden text | textContent |
| Read what is visibly rendered | innerText |
If the answer is "I just want to show this value," the property is almost always textContent.
Key Takeaways
textContentis the safest default because it treats strings as text, not HTML.innerHTMLparses strings as markup, so never feed it user-controlled text unless it has gone through a real sanitization boundary.innerTextis CSS-aware rendered text, which is useful in narrower cases and heavier than plain DOM text.- Assigning to any of these properties can replace child nodes, so update the smallest element that actually owns the text.
- Template literals do not sanitize anything. They only build strings.