Pages built from arrays
Real interfaces are rarely written by hand element by element. Search results, chat messages, and product lists arrive as arrays of data, and the page manufactures one element per entry. Every feed ever scrolled works this way.
The DOM gives three operations for it.
const li = document.createElement("li"); // 1. make a detached node li.textContent = "Ada"; // 2. fill it in list.append(li); // 3. attach it into the tree
| Step | Call | Result |
|---|---|---|
| 1 | createElement | a node not yet on the page |
| 2 | textContent | its text is set |
| 3 | append | it becomes visible |
A created element is invisible until append places it inside a node already on the page, and the reverse is just as short, since element.remove() detaches it again.
The full loop is a shape worth knowing cold.
const names = ["Ada", "Grace", "Linus"]; const list = document.querySelector("#list"); for (const name of names) { const li = document.createElement("li"); li.textContent = name; list.append(li); }
The security line between textContent and innerHTML
There is a tempting shortcut, since element.innerHTML = "<li>Ada</li>" parses a string as HTML and builds the nodes automatically.
The danger appears the moment any part of that string came from a user. A display name set to <img src=x onerror="..."> will be built as a real element, running the attacker's code in every visitor's browser. This attack is called XSS, for cross-site scripting, and it is one of the most common real-world web vulnerabilities.
| Assignment | Treats the string as | Can create elements |
|---|---|---|
textContent | plain text | no |
innerHTML | markup | yes |
textContent is immune, because a < arrives on screen as a literal < character. The browser performs the lesson 2-4 entity escaping automatically.
The working rule is enforced in real code reviews. Data, meaning anything typed by a user or fetched from a server, goes in via textContent, and innerHTML only ever receives markup written by hand.
Building the escaper by hand
escapeHtml(text) replaces the three characters that give HTML its structure, which is what textContent does internally.
function escapeHtml(text) { let out = ""; for (const ch of text) { if (ch === "&") out += "&"; else if (ch === "<") out += "<"; else if (ch === ">") out += ">"; else out += ch; } return out; } console.log(escapeHtml("Tom & Jerry")); console.log(escapeHtml("<script>alert(1)</script>")); console.log(escapeHtml("plain text"));
Output
Tom & Jerry <script>alert(1)</script> plain text
| Character | Entity |
|---|---|
& | & |
< | < |
> | > |
A for...of loop appends either the replacement or the original character to an output string, and the entities are the ones from lesson 2-4, semicolons included.
& is handled first for a reason. Escaping it last would rewrite the ampersands introduced by the other two replacements, turning < into &lt; and printing the entity instead of the character.
A list built from data
Four names in an array become four list items, with no markup written by hand.
JavaScript
const names = ["Ada", "Grace", "Linus", "Margaret"]; const roster = document.querySelector("#roster"); for (const name of names) { const li = document.createElement("li"); li.textContent = name; roster.append(li); }
| Step | Call |
|---|---|
| make the node | document.createElement("li") |
| fill it | li.textContent = name |
| attach it | roster.append(li) |
The loop is for (const name of names), and each pass runs the same three steps.
Because the markup is generated from the array, the page follows the data, so adding a name adds a row with no HTML change. That is the property every framework later automates, and the underlying calls stay these three.
The safe assignment for untrusted data
For a display name arriving from a server and shown inside a card, the safe assignment is card.textContent = name.
It treats the string as text and never as markup, and because textContent cannot create elements, a malicious name full of tags renders as harmless literal text.
| Assignment | Outcome for <img src=x onerror=...> |
|---|---|
card.textContent = name | the tag is displayed as text |
card.innerHTML = name | the element is built and the code runs |
innerHTML parses the string as HTML and would execute an XSS payload, so data goes through textContent without exception. The rule is worth applying even to data that seems trustworthy, since the source of a string is easy to lose track of as code moves.