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
- 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
Treat DOM construction like assembling furniture away from the doorway:
- Build an element with
document.createElement(). - Configure its text, classes, properties, and attributes.
- Connect it to the tree with
append().
The browser displays a created element only after it becomes connected. Data remains the source of truth; DOM nodes are the current visual representation.
state (array) -> render() -> DOM list
textContent is the normal choice for task text. Setting it treats <strong>Study</strong> as visible characters, not markup. By contrast, innerHTML invokes the HTML parser. If the string contains untrusted data, it can introduce dangerous elements or event attributes. Do not solve this by attempting your own string sanitization. Construct known markup with DOM methods and insert unknown values as text.
Self-Study Example: Render Tasks Safely
Create 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>
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 through it carefully:
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 delete and checkbox controls do not work yet. That is intentional. This section focuses on rendering; 072–073 connect events to state updates.
Attributes, Properties, and Data Attributes
HTML attributes initialize or describe elements. DOM properties expose current object state. They often reflect each other, but not always identically.
Prefer direct, typed properties where clear:
checkbox.checked = true;
button.disabled = false;
label.htmlFor = checkbox.id;
image.alt = "";
Use setAttribute() when there is no convenient property or when the exact attribute matters:
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;
Dataset values are strings. Do not store an entire application object there. Keep real data in state and place only an ID or action hint in markup.
Intermediate Example: Add and Remove Through State
These functions preserve the architecture even before events exist:
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 is absent: taskList.lastElementChild.remove() and selector tricks. Operations update state, then render. Direct element.remove() is still useful for UI that has no state or for cleanup, but it would create two sources of truth here.
Optional Advanced Example: Use a Document Fragment
replaceChildren(...items) already batches insertion effectively for this small app. A DocumentFragment is another detached container:
function renderWithFragment() {
const fragment = document.createDocumentFragment();
for (const task of tasks) {
fragment.append(createTaskItem(task));
}
taskList.replaceChildren(fragment);
}
When inserted, the fragment itself disappears and its children move into the list. Use this when it improves construction clarity; do not assume it is automatically faster without measurement.
When Is innerHTML Acceptable?
The rule is contextual, not magical: parsing a fully trusted, fixed string may be acceptable. However, beginners often later interpolate user, network, URL, or storage data into that string. A safe habit is easier:
// 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);
textContent also replaces all existing children. Do not assign it to a parent if you intend to preserve 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.
Debug by logging the state first, then the created node, then taskList.children. If the state is wrong, fix update logic. If state is right but output is wrong, inspect render().
Accessibility, Security, and Performance
Accessibility: preserve semantic list structure: tasks are li children of ul. Associate each checkbox and label. Give buttons descriptive visible names; "Delete Learn textContent" is clearer than three identical "Delete" announcements. Use a polite status for summary updates, but avoid making every keystroke chatty. Keep focus on the initiating control unless removing it requires an intentional focus strategy.
Security: values from forms, URLs, APIs, and localStorage are untrusted. Storage is not a trust boundary because users and scripts can modify it. Insert such values using textContent or text nodes. URL attributes need their own protocol validation; textContent is not a universal sanitizer.
Performance: create nodes while detached and connect them together. A simple full render is often the most reliable strategy for small lists. Rendering thousands of items may require pagination or virtualization, but complexity without evidence creates bugs. textContent avoids HTML parsing, and a single replaceChildren() gives 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.
