070: Creating, Updating, and Removing DOM Safely
Outcomes
By the end of this lesson, you can:
- update plain text safely with
textContent; - create elements with
createElement()and connect them withappend(); - set useful properties and attributes;
- remove nodes with
remove()and clear a container withreplaceChildren(); - explain why untrusted data must not be passed to
innerHTML; and - render an array of task data into an accessible list.
Retrieval Warm-Up
Before working through the examples, retrieve a few facts from your existing DOM knowledge:
- What does
querySelector()return when nothing matches? - How does a static
NodeListbehave after a new matching element is added? - Write a selector for every
libelow#task-list.
Terms
- Mutation: Any runtime change to DOM structure or content. — Source: WHATWG DOM: Mutation algorithms
textContent: Reads/writes all descendant text safely without parsing HTML. — Source: MDN: textContentcreateElement(): Creates a detached element ready to configure and append. — Source: MDN: createElement- Detached: Created but not yet appended to the document tree. — Source: MDN: createElement
append(): Inserts nodes/strings after a parent’s last child. — Source: MDN: append- Property: Script-side accessor reflecting element state (id, value, checked). — Source: WHATWG DOM: Elements
- Attribute: Markup-declared name/value setting mirrored by some properties. — Source: MDN: Glossary — Attribute
- XSS: Cross-site scripting — injecting malicious markup through unsanitized HTML. — Source: MDN: Glossary — XSS
- Render: Pipeline painting updated DOM/style state to the screen. — Source: WHATWG HTML: Rendering
- Detached node (official): "A node that has been created but not yet attached to the document tree." — Source: MDN: Node — Detached
- XSS (official): "Cross-Site Scripting — injection of malicious scripts via unsanitized HTML." — Source: MDN: Glossary — XSS
Mental Model: Build, Configure, Connect
When you construct DOM, it helps to think of assembling furniture away from the doorway. You do the work in a safe, convenient place, then move the finished piece into position:
- Build an element with
document.createElement(). - Configure its text, classes, properties, and attributes.
- Connect it to the tree with
append().
This analogy is deliberately limited. A created element is not visible merely because it exists; it becomes part of the rendered page only after it is connected to the document tree. Keep the application data as the source of truth, and treat DOM nodes as the current visual representation of that data.
state (array) -> render() -> DOM list
For task text, textContent is normally the right tool. If you assign <strong>Study</strong> to it, the browser displays those characters rather than interpreting them as markup. innerHTML takes a different path: it invokes the HTML parser. Once untrusted data is included in the string, that parser can create dangerous elements or event attributes. Do not respond by writing your own string sanitizer. Build the known structure with DOM methods and insert unknown values as text.
Self-Study Example: Render Tasks Safely
Start with this complete page:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Render todo data</title>
<script src="app.js" defer></script>
</head>
<body>
<main>
<h1>My tasks</h1>
<p id="task-summary" role="status"></p>
<ul id="task-list"></ul>
</main>
</body>
</html>
Now add app.js:
const tasks = [
{ id: "task-1", title: "Learn textContent", completed: false },
{ id: "task-2", title: "Create DOM elements", completed: true },
{ id: "task-3", title: '<img src=x onerror="alert(1)">', completed: false },
];
const taskList = document.querySelector("#task-list");
const taskSummary = document.querySelector("#task-summary");
function createTaskItem(task) {
const item = document.createElement("li");
item.dataset.taskId = task.id;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.id = `complete-${task.id}`;
checkbox.checked = task.completed;
const label = document.createElement("label");
label.htmlFor = checkbox.id;
label.textContent = task.title;
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.dataset.action = "delete";
deleteButton.textContent = `Delete ${task.title}`;
item.append(checkbox, label, " ", deleteButton);
return item;
}
function render() {
const items = tasks.map(createTaskItem);
taskList.replaceChildren(...items);
const remaining = tasks.filter((task) => !task.completed).length;
taskSummary.textContent = `${remaining} of ${tasks.length} tasks remaining.`;
}
render();
Read the example as a sequence rather than as a collection of isolated API calls:
tasksis state. Each object has a stable ID, visible title, and completion boolean.createTaskItem()receives data and returns one detachedli.dataset.taskIdcreates adata-task-idattribute for later event handling.- The checkbox receives DOM properties.
checkedrepresents its current state; setting only thecheckedattribute is not the clearest way to update a live control. label.htmlForreflects the label'sforattribute. Matching it to the input ID makes the visible task text activate the checkbox.- Task titles go into
textContent. The third title appears literally and does not create an image or execute code. - The delete button has visible, task-specific text. It is a real button and does not need custom keyboard handling.
append()accepts several nodes and a string. The string becomes a text node.replaceChildren(...items)removes previous list children and inserts the new set in one clear operation.render()derives the summary from state rather than counting DOM nodes.
The checkbox and delete controls are rendered but do not work yet. That is intentional. This part isolates rendering; lessons 072–073 connect events to state updates.
Attributes, Properties, and Data Attributes
HTML attributes describe elements and often provide their initial values. DOM properties expose the current state of the corresponding JavaScript object. They frequently reflect one another, but reflection does not mean they are interchangeable in every situation.
When a clear, typed property exists, prefer it:
checkbox.checked = true;
button.disabled = false;
label.htmlFor = checkbox.id;
image.alt = "";
Use setAttribute() when there is no convenient property or when you specifically need to control the exact attribute:
taskSummary.setAttribute("aria-live", "polite");
Use dataset for data-* attributes:
item.dataset.taskId = "task-7"; // data-task-id="task-7"
console.log(item.dataset.taskId);
delete item.dataset.taskId;
Every value exposed through dataset is a string. It is not a place to serialize an entire application object. Keep the real data in state and put only an ID or an action hint in the markup.
Intermediate Example: Add and Remove Through State
Even before event handlers exist, add and delete operations should follow the same architecture:
function addTask(title) {
const cleanTitle = title.trim();
if (cleanTitle === "") {
return;
}
tasks.push({
id: crypto.randomUUID(),
title: cleanTitle,
completed: false,
});
render();
}
function deleteTask(id) {
const index = tasks.findIndex((task) => task.id === id);
if (index === -1) {
return;
}
tasks.splice(index, 1);
render();
}
addTask("Practise safe rendering");
deleteTask("task-2");
Notice what this code does not do: it does not call taskList.lastElementChild.remove() or depend on selector tricks. The operations change state and then render the view from that state. element.remove() remains useful for UI with no corresponding state, and for cleanup, but using it here would give the state and the DOM separate sources of truth.
Optional Advanced Example: Use a Document Fragment
For this small application, replaceChildren(...items) already gives you a clear, effective insertion boundary. A DocumentFragment offers another detached container when you want construction to be explicit:
function renderWithFragment() {
const fragment = document.createDocumentFragment();
for (const task of tasks) {
fragment.append(createTaskItem(task));
}
taskList.replaceChildren(fragment);
}
When the fragment is inserted, the fragment object itself does not become a child of the list. Its children move into the list. Use this form when it makes the construction easier to understand; do not assume it is automatically faster without measuring the application that matters.
When Is innerHTML Acceptable?
The rule depends on the input. Parsing a complete, fixed string that is genuinely trusted can be acceptable. The problem is that code often evolves: a developer later interpolates a user value, network response, URL, or storage value into that string. A safer habit avoids having to remember which pieces are trusted at each call site:
// Unsafe when title is not fully trusted:
// item.innerHTML = `<span>${task.title}</span>`;
// Safe text construction:
const title = document.createElement("span");
title.textContent = task.title;
item.append(title);
There is another behavior to account for: assigning textContent replaces every existing child. Do not set it on a parent when that parent needs to retain child buttons or inputs.
Mistakes and Debugging
- Creating but not appending: log
element.isConnected. A detached node reportsfalse. - Appending the same node twice: a node moves; it is not copied. Use
cloneNode()only when a real duplicate is required, and then repair duplicate IDs. - Destroying children with
textContent: setting a container's text removes its existing descendants. - Using
innerHTMLwith task text: this creates an injection sink. Use known elements plustextContent. - Confusing
append()and arraypush():push()updates state arrays;append()connects DOM nodes. - Using
setAttribute("checked", "false"): Boolean attribute presence means true. Setcheckbox.checked = falsefor current state or remove the attribute. - Duplicating IDs: labels can activate the wrong control. Generate stable unique IDs.
- Removing only from DOM: the next
render()restores the item because state still contains it.
When debugging, inspect the boundaries in order: log the state first, then the created node, then taskList.children. If the state is already wrong, fix the update logic. If state is correct but the page is wrong, inspect render() and the nodes it creates.
Accessibility, Security, and Performance
Accessibility: keep the semantic list structure intact: tasks belong in li children of ul. Associate every checkbox with its label. Buttons should have descriptive visible names; "Delete Learn textContent" tells a screen-reader user more than three identical "Delete" announcements. A polite status is appropriate for summary updates, but avoid announcing every keystroke. Normally keep focus on the control that initiated the change; if that control is removed, use an intentional focus strategy.
Security: treat values from forms, URLs, APIs, and localStorage as untrusted. Storage is not a trust boundary because both users and scripts can modify it. Insert these values with textContent or text nodes. Attributes that contain URLs need their own protocol validation; using textContent does not sanitize every kind of value.
Performance: construct nodes while they are detached and connect them as a group. For a small list, a straightforward full render is often the most dependable strategy. A list with thousands of items may need pagination or virtualization, but adding that complexity without evidence also creates bugs. textContent avoids HTML parsing, and one replaceChildren() establishes a clear update boundary.
Exercises
Core
Add a priority <span> to every task. Its text should be High priority or Normal priority based on a priority state field.
Practice
When no tasks remain, render one <li>No tasks yet.</li> and change the summary to No tasks remaining.
Professional Extension
Write toggleTask(id) to change only the matching task's completed value and render again.
Core
Add priority: "high" or priority: "normal" to each object, then add this before the button:
const priority = document.createElement("span");
priority.textContent = task.priority === "high"
? "High priority"
: "Normal priority";
item.append(checkbox, label, " - ", priority, " ", deleteButton);
Practice
function render() {
if (tasks.length === 0) {
const emptyItem = document.createElement("li");
emptyItem.textContent = "No tasks yet.";
taskList.replaceChildren(emptyItem);
taskSummary.textContent = "No tasks remaining.";
return;
}
taskList.replaceChildren(...tasks.map(createTaskItem));
const remaining = tasks.filter((task) => !task.completed).length;
taskSummary.textContent = `${remaining} of ${tasks.length} tasks remaining.`;
}
Professional Extension
function toggleTask(id) {
const task = tasks.find((item) => item.id === id);
if (!task) {
return;
}
task.completed = !task.completed;
render();
}
toggleTask("task-1");
Recap
- Build, configure, then connect DOM elements.
- Use
textContentfor untrusted plain text andcreateElement()for known structure. - Properties often represent live control state more clearly than attributes.
append(),remove(), andreplaceChildren()are modern mutation tools.- Keep task data in state; let
render()recreate the view. - Update state first, then render again.
