Creating HTML Dynamically

Introduction

In this lesson we will cover different approaches to creating HTML elements from data in JavaScript. When we create HTML files, the content is static - meaning that it cannot change on its own. We need a scripting language like JavaScript to create, update or remove HTML content from a web page. Although this is a common task, there are many different ways to approach it with their own advantages and disadvantages.

Some common situations that require dynamic HTML creation include:

  • Creating a grid of product thumbnail items from a list of products
  • Creating a list of blog posts from a list of blog post objects
  • Creating a user profile page from a user object in local storage

Any web project that uses an external data source, such as an API will need some form of dynamic HTML strategy.

Safety First

The topic of HTML rendering may be your first introduction to the concept of web safety. Typically, working with browser technologies is free of danger, however there are some situations where extra caution should be exercised. Dynamically rendering HTML content means that you place some amount of trust to the source of the data.

Let’s use a blog comment section as an example, a user can leave a comment that is then rendered and shown to other users. There is potential for the commenter to include malicious code in their comment that could be executed by the browser. For example, they could include a script tag that loads a malicious script from a remote server. This is called cross-site scripting or XSS for short.

We will discuss how this affects our choices later on in the lesson.

document.createElement()

First, let’s examine the most robust approach to HTML creation, document.createElement(). This method allows us to create any HTML element we want, but it will not automatically add it to the page for the user to see. In order for the user to see our HTML it must be placed into the DOM after it has been created:

javascript
	// Step 1: Create the element
	const button = document.createElement("button");
	// Step 2: Add the element to the DOM
	document.body.append(button);

If you try this example in an empty browser tab, you may notice an empty button appears on the screen. This is because the browser will render the button element, but it will not have any content. We can add content to the button by setting the textContent property:

javascript
	button.textContent = "Click Me";

In fact, we can change this property at any time to update the text content of the button:

javascript
	button.addEventListener("click", () => {
	  button.textContent = "Clicked!";
	});

We can access any of the properties of the button element, including the style property:

javascript
	button.style.backgroundColor = "red";
	button.style.color = "white";

We can disable the button by setting the disabled property to true:

javascript
	button.disabled = true;

And we can assign id or class values to identify the button:

javascript
	button.id = "example-button";
	button.classList.add("btn");

Let’s put all of these snippets together into a single script:

javascript
	const button = document.createElement("button");
	
	button.textContent = "Click me!";
	
	button.classList.add("btn");
	
	button.id = "example-button";
	
	button.addEventListener("click", () => {
	  button.textContent = "Clicked!"
	  button.disabled = true;
	  button.style.backgroundColor = "red";
	  button.style.color = "white";
	})
	
	document.body.append(button);

Finally, we can “wrap” or encapsulate this code into a function:

javascript
	function createButton(beforeText, afterText) {
	  const button = document.createElement("button");
	
	  button.textContent = beforeText;
	
	  button.classList.add("btn");
	
	  button.id = "example-button";
	
	  button.addEventListener("click", () => {
	    button.textContent = afterText;
	    button.disabled = true;
	    button.style.backgroundColor = "red";
	    button.style.color = "white";
	  })
	
	  document.body.append(button);
	}
	
	createButton("Click me!", "Clicked!")

From the example above we can see how a series of instructions can be grouped together into a convenient and reliable function. Each time we call the function, a new button will be added with the provided text.

Element.innerHTML

An alternative approach to creating HTML elements is to use the innerHTML property of an existing element. Unlike in the previous approach, where we handle a JS object that represents an HTML element - when using innerHTML we handle a string value that contains valid HTML. This approach is often preferred by new JavaScript developers, who find it more intuitive and convenient than createElement - however it is important to be aware of the pitfalls of this method.

Let’s take a look at the button example from before, using this approach:

javascript
	document.body.innerHTML = '<button>Click Me</button>';

If we need to add additional attributes, this can be done easily by editing the template:

javascript
	document.body.innerHTML = '<button class="btn">Click Me</button>';

We can include all of the additional attributes from above quite easily and without adding extra lines of code:

javascript
	document.body.innerHTML = '<button disabled style="background-color:red;" class="btn">Click Me</button>';

We can also use this approach to create more complex HTML structures, such as a list of items:

javascript
	document.body.innerHTML = `
	  <ul>
	    <li>Item 1</li>
	    <li>Item 2</li>
	    <li>Item 3</li>
	  </ul>
	`;

Using string literals in this way allows for easy substitution of data:

javascript
	const buttonText = "Click me!";
	document.body.innerHTML = `<button>${buttonText}</button>`;

Setting Event Listeners

However, when it comes to tasks such as setting event listeners - this approach runs into a major problem. In the createElement example we were able to easily set a click event listener on the button to change the text. If we try to do the same thing in our current example, it is not so straightforward:

javascript
	document.body.innerHTML = '<button>Click Me</button>';
	const button = document.querySelector("button");
	button.addEventListener("click", () => {
	  button.textContent = "Clicked!";
	});

The problem here is that once we create the element in the first line of the snippet, we lose access to it - and have to find it again by using querySelector. Only with a reference to the element can we set an event listener - and using innerHTML does not provide us with a reference!

One way to think about this is through a physical analogy. Imagine that you have a bag containing coloured balls and a marker pen. You have two tasks:

  1. Add a red ball to the bag.
  2. Write “Hello World” onto the same red ball that you added.

You have two options on how to proceed with this:

Option A

  1. Place a new red ball into the bag, before writing on it.
  2. Search the contents of the bag until you find the correct ball.
  3. Write the text on the ball and return it to the bag.

Option B

  1. Write the text on a new red ball and place it into the bag.

Not only does option A require more steps to complete, but depending on the number and color of the balls already in the bag - this task might become impossible. For example, if there is already more than one red ball in the bag - you will not be able to tell which ball is which and may fail the task! Since you have a pen, you may as well mark the balls yourself before you place them into the bag, so it doesn’t take so long to find them again:

Option A updated

  1. Pick a red ball and write a unique message onto it.
  2. Place this marked ball into the bag, without writing anything else.
  3. Search the contents of the bag until you find the correct ball.
  4. Write the text on the ball and return it to the bag.

This has solved the problem, but it has solved the problem by introducing more complexity. We can describe this approach as unreasonable because it takes unnecessary steps to accomplish an otherwise simple goal.

Going back to the code, the example above contains only one button - but what if the page in question has 1,000 buttons? It would be difficult to know that document.querySelector("button") was selecting the correct button and we may end up with unpredictable behaviour. To avoid this, we would have to mark each button with a unique id so that we can find it again:

javascript
	const uniqueId = Crypto.randomUUID();
	document.body.innerHTML = `<button id="${uniqueId}">Click Me</button>`;
	const button = document.querySelector(`#${uniqueId}`);
	button.addEventListener("click", () => {
	  button.textContent = "Clicked!";
	});

While this works, it adds more complexity to what should be a simple task. What’s more, we now have the responsibility of tracking all of these IDs and making sure there are no clashes or that they are not reused in other parts of the application.

Security Implications

There is an additional risk when it comes to using innerHTML, which is the potential for cross site scripting, as mentioned earlier. If your data source is beyond your control, such as a hosted database or API, there is an inherent risk that an innerHTML call could be hijacked to execute malicious code. Let’s take a look at an example:

javascript
	const maliciousData = '<img src="https://picsum.photos/200/300" onerror="alert(\'Attack Simulation!\')" />';
	document.body.innerHTML = maliciousData;

In this case, the onerror attribute of the img tag is used to execute a JavaScript alert. Although this example is harmless, it shows how content outside your control could be hijacked in different ways. There are ways to workaround this problem, but these are inconvenient and require third party software libraries.

Overall, this method can be useful in some situations - but it should not form the backbone of any HTML rendering approach.

DOMParser

Before we discuss this approach, let’s recap on the advantages and disadvantages of the previous two methods:

Method Advantages Disadvantages
createElement Fast, secure, robust, easy to set event listeners Verbose, inconvenient, harder to learn
innerHTML Convenient, easy to learn, easy to use Insecure, hard to set event listeners

The DOMParser approach aims to combine the best of both worlds, by offering a way to create HTML from a string in the same way as innerHTML - but with the added benefit of easy event handling. It should be noted that this approach suffers from the same security risks as innerHTML, and should not be used in situations where the data source is not trusted. For example, an API that accepts user submitted content would be such a case.

Let’s take a look at a simple DOMParser example:

javascript
	// Define the HTML template
	const template = '<button>Click Me</button>';
	// Create a new parser tool
	const parser = new DOMParser();
	// Convert the template into a DOM object
	const parsedDocument = parser.parseFromString(template, "text/html");
	// Extract the button element from the DOM object
	const button = parsedDocument.body.firstChild;
	// Render the button by adding it to our document
	document.body.append(button);

Compared with the previous examples, we can see some similarities from both approaches:

  • The element is created from a string template that looks like normal HTML.
  • The element is appended to the document in the same way as using createElement.

However, this approach requires more lines of code - since there are additional steps such as setting up the parser and extracting the correct element from the output of this method. It makes sense to encapsulate these extra steps since so that they can be used more easily elsewhere in the code:

javascript
	export function createHTML(template) {
	  const parser = new DOMParser();
	  const parsedDocument = parser.parseFromString(template, "text/html");
	  return parsedDocument.body.firstChild;
	}
	
	const button = createHTML("<button>Click me!</button>");
	document.body.append(button);

By encapsulating these steps, we can quickly and easily use this function to create HTML. By using this approach, we can set event listeners before adding an element to the document, and we can update that element’s values after it has been added to the document in the same way as with the createElement approach above:

javascript
	// Create the button element
	const button = createHTML("<button>Click me!</button>");
	// Set a click event listener
	button.addEventListener("click", event => {
	  // Update the text content when clicked
	  button.textContent = "Clicked!";
	})
	// Add the button to the document
	document.body.append(button);
	// Update the background color after insertion
	button.style.backgroundColor = "red";

Finally, this approach can be used with dynamic data in the same way as the innerHTML example:

javascript
	const buttonText = "Click me!";
	const button = createHTML(`<button>${buttonText}</button>`);
	document.body.append(button);

This makes DOMParser a convenient and attractive approach to handling dynamic HTML creation. However, it is important to remember that this approach is not secure and should not be used in situations where the data source is not trusted. The same rules apply as with innerHTML - if you are not in control of the data, you cannot trust it and you should avoid rendering it directly.

Example

Let’s look at a complete example for creating a thumbnail for a blog post using these various methods. First, we will need to define our object, although this could come from an API or other data source:

javascript
	const post = {
	  title: "My Blog Post",
	  author: "John Doe",
	  published: "2021-01-01",
	  content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
	  image: "https://picsum.photos/200/300",
	};

Next we need to think about the shape of the HTML markup that we will need to show this item on the frontend. This is what we will be creating dynamically within JavaScript:

html
	<article class="post">
	  <h2 class="post-title">My Blog Post</h2>
	  <p class="post-author">John Doe</p>
	  <p class="post-published">2021-01-01</p>
	  <img class="post-image" src="https://picsum.photos/200/300" />
	  <button>Read more!</button>
	</article>

We will also need a function that is capable of accepting the object and returning the complete HTML markup, this is where our approach will differ depending on the tool in use. Let’s sketch out a boilerplate version of this function:

javascript
	export function postTemplate(postData) {
	  // return the completed markup
	}

The function above doesn’t show anything to the user, it simply arranges the markup into a format that is ready to be inserted into the document. Although we could do this at the same time as rendering or inserting the content into the document - it helps to separate these concerns into different functions.

Finally, we need a function to pass the data to the function above and insert the result into the DOM. Again, how this will be done may differ depending on the approach, so we will just sketch out the outline of a function:

javascript
	export function renderPost(postData) {
	  // Get the container element
	  const container = document.querySelector(".container");
	
	  // Create the post element
	  const post = postTemplate(postData);
	
	  // Add the post to the container
	  // See examples below!
	}

We will keep the same function names for each example, but use a different implementation to show the differences between each approach.

document.createElement()

Using this method is the most long-winded, as each class name and attribute must be set individually. However, it is the fastest and most robust method, requiring no additional tricks or workarounds.

javascript
	export function postTemplate(postData) {
	  // Create the article element
	  const article = document.createElement("article");
	  article.classList.add("post");
	
	  // Create the title element
	  const title = document.createElement("h2");
	  title.classList.add("post-title");
	  title.textContent = postData.title;
	
	  // Create the author element
	  const author = document.createElement("p");
	  author.classList.add("post-author");
	  author.textContent = postData.author;
	
	  // Create the published element
	  const published = document.createElement("p");
	  published.classList.add("post-published");
	  published.textContent = postData.published;
	
	  // Create the image element
	  const image = document.createElement("img");
	  image.classList.add("post-image");
	  image.src = postData.image;
	
	  // Create the button element
	  const button = document.createElement("button");
	  button.textContent = "Read more!";
	
	  // Set an event listener to show the content
	  button.addEventListener("click", () => {
	    alert(postData.content)
	  });
	
	  // Add all elements to the article
	  article.append(title, author, published, image, button);
	
	  // Return the article
	  return article;
	}

Since this approach returns an HTMLElement object instead of a string, we can use append to finish the process in the render function:

javascript
	export function renderPost(postData) {
	  // Get the container element
	  const container = document.querySelector(".container");
	
	  // Create the post element
	  const post = postTemplate(postData);
	
	  // Add the post to the container
	  container.append(post);
	}
	
	renderPost(post);

Refactoring

The example above groups many expressions into a single, large function. Generally, when working with code we try to avoid this by splitting a large block up into smaller named functions. This makes the code easier to read, test and debug:

javascript
	function createArticle() {
	  const article = document.createElement("article");
	  article.classList.add("post");
	  return article;
	}
	
	function createTitle(title) {
	  const element = document.createElement("h2");
	  element.classList.add("post-title");
	  element.textContent = title;
	  return element;
	}
	
	function createAuthor(author) {
	  const element = document.createElement("p");
	  element.classList.add("post-author");
	  element.textContent = author;
	  return element;
	}
	
	function createPublished(published) {
	  const element = document.createElement("p");
	  element.classList.add("post-published");
	  element.textContent = published;
	  return element;
	}
	
	function createImage(image) {
	  const element = document.createElement("img");
	  element.classList.add("post-image");
	  element.src = image;
	  return element;
	}
	
	function createButton(content) {
	  const element = document.createElement("button");
	  element.textContent = "Read more!";
	  element.addEventListener("click", () => {
	    alert(content)
	  });
	  return element;
	}
	
	export function postTemplate(postData) {
	  const article = createArticle();
	  const title = createTitle(postData.title);
	  const author = createAuthor(postData.author);
	  const published = createPublished(postData.published);
	  const image = createImage(postData.image);
	  const button = createButton(postData.content);
	
	  article.append(title, author, published, image, button);
	
	  return article;
	}

While this approach hardly saves us in terms of lines of code - it does make the postTemplate function very easy to read. These building blocks can be exported and used elsewhere in the project to increase consistency.

One way or the other, createElement is a verbose approach to creating complex HTML arrangements. What it lacks in convenience, it makes up for in performance and security.

innerHTML

Let’s examine the same task, but using the innerHTML approach. This will be much more convenient, but it will also be less secure and more difficult to set event listeners.

javascript
	export function postTemplate(postData) {
	  return `
	    <article class="post">
	      <h2 class="post-title">${postData.title}</h2>
	      <p class="post-author">${postData.author}</p>
	      <p class="post-published">${postData.published}</p>
	      <img class="post-image" src="${postData.image}" />
	      <button>Read more!</button>
	    </article>
	  `;
	}

Compared with the previous implementation, this is much easier to read and understand - and it takes up less space on the page. However, this is only half of the process and we still need to show this template on the page. We can do this by using the innerHTML property of an existing element:

javascript
	export function renderPost(postData) {
	  // Get the container element
	  const container = document.querySelector(".container");
	
	  // Create the post element
	  const post = postTemplate(postData);
	
	  // Add the post to the container
	  container.innerHTML = post;
	
	  // Set click event listener
	}

While this method uses much less code, the example is not complete. We are missing the event listener to show the alert message on button click. Adding this requires searching the entire document again:

javascript
	export function renderPost(postData) {
	  // Get the container element
	  const container = document.querySelector(".container");
	
	  // Create the post element
	  const post = postTemplate(postData);
	
	  // Add the post to the container
	  container.innerHTML = post;
	
	  // Set click event listener
	  const button = document.querySelector("article > button");
	  button.addEventListener("click", () => {
	    alert(postData.content)
	  });
	}

Let’s revisit the issues with the approach above:

  1. If there are more than one button elements on the page, the event listener might be assigned to the wrong button.
  2. The event handler has to be declared away from the template - making the code more fragile, harder to read and understand.
  3. This approach is considerably slower to execute because the entire document must be searched for the button element.
  4. This approach is not safe to use with external data due to the risk of cross site scripting.

DOMParser

Finally, let’s run through this example using the DOMParser approach. We can reuse the same postTemplate function from above since there is no difference in this stage of the process:

javascript
	export function postTemplate(postData) {
	  return `
	    <article class="post">
	      <h2 class="post-title">${postData.title}</h2>
	      <p class="post-author">${postData.author}</p>
	      <p class="post-published">${postData.published}</p>
	      <img class="post-image" src="${postData.image}" />
	      <button>Read more!</button>
	    </article>
	  `;
	}

Next, we will need to define our parser function to transform the string into an HTML object, this is the same as the example in the DOMParser section earlier in the lesson:

javascript
	export function createHTML(template) {
	  const parser = new DOMParser();
	  const parsedDocument = parser.parseFromString(template, "text/html");
	  return parsedDocument.body.firstChild;
	}

We also need a function to attach the event listener before we render the elements:

javascript
	export function createPostHTML(postData) {
	  const post = postTemplate(postData);
	  const postElement = createHTML(post);
	  const button = postElement.querySelector("button");
	  button.addEventListener("click", () => {
	    alert(postData.content)
	  });
	  return postElement;
	}

Although querySelector is still needed to access the button element in this example, the performance impact is not as severe as with the innerHTML approach. This is because we are only searching within the postElement instead of the entire document.

Finally, we need to render the post so that the user can see it on the page:

javascript
	export function renderPost(postData) {
	  // Get the container element
	  const container = document.querySelector(".container");
	
	  // Create the post element
	  const post = createPostHTML(postData);
	
	  // Add the post to the container
	  container.append(post)
	}

This approach combines some of the best aspects of both createElement and innerHTML - while compromising in many of the same areas:

  • In terms of performance, it is faster than innerHTML but slower than createElement.
  • In terms of security, it is slightly more secure than innerHTML but considerably less secure than createElement.
  • In terms of convenience, it is more convenient than createElement but less convenient than innerHTML and requires some additional functions to work.

Selecting an Approach

It may seem puzzling that there are such different ways to achieve such an every-day task in JavaScript - throughout the years there have been many libraries and packages designed to make this process more standardised and less problematic. In fact, this is one of the main tasks of a front-end framework such as React, Vue, Svelte, or even jQuery. These frameworks provide a standardised way to create HTML from data, and they also provide a way to handle events and state changes safely and with good performance.

Any framework that you use in your career is fundamentally “made from JavaScript” - meaning that however a framework solves a particular problem, it will use the same tools that we discuss in this lesson. It is important to understand DOM operations such as creating HTML from the ground up, so that you can understand how these frameworks work and how to use them effectively in future.

Within a framework there is often a single “way of doing” for each common task. Using JavaScript on its own means that we will often have multiple options for how to solve a problem. Whether an approach is “right” or “wrong” will depend on the circumstances and the priorities of the developer. For example, an application that does not use any external data sources like a word game may be able to completely disregard the security implications of innerHTML and use it freely. On the other hand, an application that uses external data sources such as a banking app may need to use createElement to avoid the risk of cross site scripting.

The following guides can be applied:

  • createElement is the most performant, robust and secure approach in any circumstance. If in doubt, select this approach.
  • innerHTML is extremely useful, especially for working with complex HTML structures but cannot be used with external data sources.
  • DOMParser is a good compromise between the two approaches, but it is not secure and should not be used with external data sources.

Lesson Task

Brief

Create a dynamic image gallery application, that renders a grid of images based on an array of image URLs. Use a search engine or generative AI tool to create a list of 10 image URLs, these should either be saved to your project or available on the internet.

Process

  1. Create an HTML file called gallery.html.
  2. Create a JS file called gallery.js.
  3. Link the JS file to the HTML file using a script tag.
  4. Find 10 image URLs and save them to an array in the JS file.
  5. Create a function called createImage that accepts an image URL and returns an img element with the src attribute set to the image URL.
  6. Create a function called renderImage that shows an image on the page by appending.
  7. Create a function called renderGallery that loops through the array of image URLs and renders each image using the renderImage function.

Advanced Process

  1. Add a URL type input field to the HTML file.
  2. Add a button to the HTML file.
  3. Create a function called newImage that reads the value of the input field and adds it to the array of image URLs.

Solution

You can find an interactive solution here.