Q1Right after const li = document.createElement("li");, where on the page is li?
Creating, Changing, and Removing Elements — createElement / classList / remove
Covers createElement and append for adding elements, remove for taking them out, classList and dataset for changing classes and attributes, and textContent for showing input as plain text.
If you assign to innerHTML for every change, such as adding an item to a list, marking one as done, or deleting one, you rebuild even the li elements you meant to keep. And if you assign user input the same way, any tags mixed into it, such as <a>, become elements too.
This article covers how to create and remove elements, classList and dataset for changing classes and attributes, and how to insert input as plain text.
Creating and Adding Elements — createElement and append
Say you want to add "Send the invoice" to the end of a task list. If you tack a string onto innerHTML and assign it back, every li already in the list is rebuilt as a new element too. An li you stored in a variable earlier with querySelector still points to the old element, which is no longer on the page, so changing its text through that variable doesn't change what's displayed.
createElement (a method that takes a tag name and creates a new element that isn't anywhere in the DOM tree yet) is called like document.createElement("li"). The element you create becomes part of the page once you add it to a parent element with append (a method that adds the element you pass to the end of the calling element's children).
document.body.innerHTML = `<ul id="tasks"><li>Prepare the quote</li><li>Share the meeting notes</li></ul>`;
const taskList = document.querySelector("#tasks");
// Create an li element and put text in it (it isn't on the page yet)
const newTask = document.createElement("li");
newTask.textContent = "Send the invoice";
console.log(newTask.outerHTML); // <li>Send the invoice</li>
console.log(document.querySelectorAll("li").length); // 2
// Adding it to the end of the ul's children makes it part of the page
taskList.append(newTask);
console.log(document.querySelectorAll("li").length); // 3
console.log(taskList.outerHTML);
// <ul id="tasks"><li>Prepare the quote</li><li>Share the meeting notes</li><li>Send the invoice</li></ul>
The variable newTask points to the same li element before and after append. So if you assign to newTask.textContent after calling append, the text of the third li on the page changes.
Marking Tasks as Done — classList and dataset
Next, you want to add a done class to a finished task so that CSS shows it with a strikethrough. The li's class attribute is a single string, task urgent, so if you overwrite it with done, you also lose the task class that CSS uses to style the row.
classList (an object for working with an element's classes one word at a time) has add to add a word, remove to take one out, and toggle to add it if it's missing or remove it if it's there. Attributes other than class are written with setAttribute(name, value), and attributes that start with data- are written through dataset (an object for reading and writing data attributes by name).
document.body.innerHTML = `<ul><li class="task urgent" data-id="T-1">Prepare the quote</li><li class="task urgent" data-id="T-2">Send the invoice</li></ul>`;
const quote = document.querySelector('[data-id="T-1"]');
const invoice = document.querySelector('[data-id="T-2"]');
// classList adds and removes one word at a time (the other classes stay)
quote.classList.add("done");
console.log(quote.outerHTML); // <li class="task urgent done" data-id="T-1">Prepare the quote</li>
quote.classList.toggle("urgent"); // It's done, so drop urgent (it's present, so toggle removes it)
console.log(quote.outerHTML); // <li class="task done" data-id="T-1">Prepare the quote</li>
// setAttribute replaces the whole value (using it on class wipes out task too)
invoice.setAttribute("class", "done");
console.log(invoice.outerHTML); // <li class="done" data-id="T-2">Send the invoice</li>
// Write attributes that start with data- through dataset (doneAt becomes data-done-at)
invoice.dataset.doneAt = "9/10";
console.log(invoice.outerHTML);
// <li class="done" data-id="T-2" data-done-at="9/10">Send the invoice</li>
The name you use with dataset is the attribute name with data- removed and the letter after each remaining hyphen capitalized. You read and write data-id as dataset.id and data-done-at as dataset.doneAt, and assigning to them writes the attribute into the HTML under its original name.
dataset Doesn't Accept Hyphenated Names
If you read with the hyphen left in, as in dataset["done-at"], you get undefined instead of an error, so the typo is easy to miss. Assigning that way throws an error on that line. The error is named SyntaxError, but unlike a real syntax error in your code, it happens at run time, so the lines before it still run.
Removing Elements — remove and References Left in Variables
Now suppose you want to take a finished task out of the list. If you rewrite the list with innerHTML, you also have to write the classes and data attributes added in the previous section back into the HTML string, and if you miss even one, a task you kept loses its done marker.
remove (a method that detaches the calling element from its parent and removes it from the DOM tree) is called with no arguments on the element you want to delete, as in doneItem.remove(). The classList.remove("done") from the previous section only takes a word out of the class. It's a different method from this one, which removes the element itself.
document.body.innerHTML = `<ul id="tasks"><li data-id="T-1">Prepare the quote</li><li data-id="T-2" class="done">Send the invoice</li><li data-id="T-3">Share the meeting notes</li></ul>`;
const tasks = document.querySelectorAll("li");
// Find the finished li and remove it from the DOM tree
const doneItem = document.querySelector(".done");
doneItem.remove();
console.log(document.querySelectorAll("li").length); // 2
// The removed element and the NodeList you got earlier both stay in their variables
console.log(doneItem.textContent); // Send the invoice
console.log(tasks.length); // 3 (the elements found at that time)
// It's still in the variable, so append can put it back at the end of the list
document.querySelector("#tasks").append(doneItem);
console.log(document.querySelectorAll("li").length); // 3
querySelectorAll just returns the elements it found at the moment you called it; it doesn't track later changes to the DOM. The removed doneItem also stays in its variable, so you can still read its text or put it back into the list with append.
Showing Input as Text — textContent and innerHTML
Consider a product review section that shows comments posted by users. Comments are saved, and the same string appears on other visitors' pages too. If a comment contains an a tag (a tag that creates a link, with the destination in its href attribute), assigning it to innerHTML turns the fake link the poster planted into a real link on the page.
A string assigned to innerHTML is parsed as HTML, and its tags become elements. A string assigned to textContent isn't parsed, so even < goes in as a plain character. Putting input into innerHTML doesn't just let people plant fake links; it can also lead to XSS (a vulnerability where code a user slips into their input runs on other visitors' pages).
document.body.innerHTML = `<p class="review"></p>`;
const review = document.querySelector(".review");
// A string a user typed into the review field (with a fake link mixed in)
const reviewText = '<a href="https://example.com/login">Log in again</a> to continue';
// Put into innerHTML, the a tag becomes a real a element
review.innerHTML = reviewText;
console.log(document.querySelectorAll("a").length); // 1
// Put into textContent, the tags go in as plain text
review.textContent = reviewText;
console.log(document.querySelectorAll("a").length); // 0
console.log(review.outerHTML);
// <p class="review"><a href="https://example.com/login">Log in again</a> to continue</p>
- The text inside is "Log in again"
- Clicking it goes to another site
- Follows the a element
<a href="…">Log in again</a> to continue- 0 a elements
The < and > in outerHTML are how HTML writes < and > as text. When you read them back with textContent, you get the original characters. The table below sorts the ways of changing elements covered in this article by what they change.
| What you change | How to write it | What to watch for |
|---|---|---|
| The element itself | createElement / append / remove | Not on the page until append |
| class | classList.add / remove / toggle | setAttribute replaces the whole value |
| Attributes starting with data- | dataset.doneAt = "9/10" | Drop data-, capitalize after hyphens |
| Other attributes | setAttribute("href", "/tasks/T-2") | Adds it, or replaces its value |
| Text content | textContent | Put user input here |
| HTML content | innerHTML | Only HTML you wrote yourself |
Code in an img's onerror Still Runs
A <script> put into innerHTML doesn't run, but if an img tag, which displays an image, is written as <img src="x" onerror="…">, onerror (an attribute holding code that runs when loading fails) does run. So the fact that <script> doesn't run doesn't make it safe to put input into innerHTML.
Knowledge Check
Answer each question one by one.
Q2If an li's class is task urgent, what is its class after setAttribute("class", "done")?
Q3After p.textContent = "<b>New</b>";, how many b elements are inside p?