FullStack Course LogoFullStack Course
Module: JavaScript
JavaScript·070·8 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

Before working through the examples, retrieve a few facts from your existing DOM knowledge:

  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

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:

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

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

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>

Now 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 the example as a sequence rather than as a collection of isolated API calls:

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

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

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;

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:

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

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

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);

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 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.

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:

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

Reader page: /javascript/lesson/070/creating-updating-and-removing-dom-safely