Module: JavaScript
JavaScript·070·7 MIN READ

070: Creating, Updating, and Removing DOM Safely

TOPICS COVERED: 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 with append();
  • set useful properties and attributes;
  • remove nodes with remove() and clear a container with replaceChildren();
  • explain why untrusted data must not be passed to innerHTML; and
  • render an array of task data into an accessible list.

Retrieval Warm-Up

  1. What does querySelector() return when nothing matches?
  2. How does a static NodeList behave after a new matching element is added?
  3. Write a selector for every li below #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: textContent
  • createElement(): 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:

  1. Build an element with document.createElement().
  2. Configure its text, classes, properties, and attributes.
  3. 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.

text
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:

html
<!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:

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:

  1. tasks is state. Each object has a stable ID, visible title, and completion boolean.
  2. createTaskItem() receives data and returns one detached li.
  3. dataset.taskId creates a data-task-id attribute for later event handling.
  4. The checkbox receives DOM properties. checked represents its current state; setting only the checked attribute is not the clearest way to update a live control.
  5. label.htmlFor reflects the label's for attribute. Matching it to the input ID makes the visible task text activate the checkbox.
  6. Task titles go into textContent. The third title appears literally and does not create an image or execute code.
  7. The delete button has visible, task-specific text. It is a real button and does not need custom keyboard handling.
  8. append() accepts several nodes and a string. The string becomes a text node.
  9. replaceChildren(...items) removes previous list children and inserts the new set in one clear operation.
  10. 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:

js
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:

js
taskSummary.setAttribute("aria-live", "polite");

Use dataset for data-* attributes:

js
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:

js
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:

js
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:

js
// 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 reports false.
  • 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 innerHTML with task text: this creates an injection sink. Use known elements plus textContent.
  • Confusing append() and array push(): push() updates state arrays; append() connects DOM nodes.
  • Using setAttribute("checked", "false"): Boolean attribute presence means true. Set checkbox.checked = false for 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:

js
const priority = document.createElement("span");
priority.textContent = task.priority === "high"
  ? "High priority"
  : "Normal priority";

item.append(checkbox, label, " - ", priority, " ", deleteButton);

Practice

js
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

js
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 textContent for untrusted plain text and createElement() for known structure.
  • Properties often represent live control state more clearly than attributes.
  • append(), remove(), and replaceChildren() are modern mutation tools.
  • Keep task data in state; let render() recreate the view.
  • Update state first, then render again.

Official References