Updating HTML Content Dynamically

Introduction

In this lesson, we will explore approaches to updating existing HTML content dynamically. For example, within a time-sensitive application such as an auction site, we may want to update the current bid amount on the page without reloading the entire page. This requires similar skills to the previous lesson on creating HTML elements, however there are some additional considerations that we will discuss.

The approaches to updating HTML content can be broadly grouped into two categories:

  • Patch - Find an existing element and update it’s properties
  • Rerender - Remove the existing element and replace it with an updated copy

We can think of these two approaches as being roughly analogous to createElement vs innerHTML in the previous lesson - wherein one approach is more robust but slightly less convenient and the other approach is more error-prone but considerably more convenient.

Imagine that you find a hole in your favourite pair of socks. You have two approaches to resolve this issue:

  1. Patch the socks with thread and fabric
  2. Buy an identical pair of socks and throw the old pair away

Patching the socks with thread is a labour intensive, but cost-effective way to solve the issue. How long it will take depends on the severity of the hole, and introduces the risk of bad-sewing leading to a worse problem. Buying a new pair of socks is a quick and easy way to solve the issue, but is more expensive and wasteful. The size of the hole is not important, since the entire sock is replaced.

This is very similar to the decision process we must make when updating data-driven HTML in a web application. Let’s look at each approach in more detail.

Patching

The first approach involves a process of finding an existing element on the page using querySelector then updating it’s properties as needed. Let’s look at a very simple example of a patch operation:

html
	<p>Hello, world!</p>
javascript
	const p = document.querySelector('p');
	p.textContent = 'Goodbye, world!';

This operation happens so quickly when we view the page, that it is barely noticeable. Let’s introduce a timer to make this change more obvious:

javascript
	const p = document.querySelector('p');
	setTimeout(() => {
	  p.textContent = 'Goodbye, world!';
	}, 2000);

Now the change will only take effect after 2 seconds, showing that we can update HTML elements over time without reloading the entire page. This is a key part of the concept of dynamic web applications.

Using this approach we are only changing the specific properties that need to change, and leaving the rest of the HTML untouched.

Realistic Example

Let’s take a look at a more detailed and realistic example using a “skeleton” style loading animation for a blog website. In this example we will simulate an API request and use the data to update an empty skeleton layout.

html
	<button onclick="example()">Finish loading</button>
	<button onclick="reset()">Reset Example</button>
	<div class="container">
	  <article class="loading" id="">
	    <img src="" alt="" />
	    <h2></h2>
	    <p></p>
	  </article>
	</div>

First we add the template or “shape” of our markup that we will populate with data later. Next, some styles to create the pulsing load animation:

css
	article {
	  padding: 1rem;
	  max-width: 800px;
	  font-family: sans-serif;
	}
	
	article,
	article img,
	article.loading h2,
	article.loading p {
	  background-color: #aaaaaa99;
	  border-radius: 4px;
	}
	
	article img {
	  width: 100%;
	  height: 200px;
	  margin-bottom: 10px;
	  object-fit: cover;
	}
	
	article.loading h2 {
	  width: 70%;
	  min-height: 1.5em;
	}
	
	article.loading p {
	  width: 100%;
	  min-height: 1.2em;
	}
	
	/* Keyframe for pulsing effect */
	@keyframes pulse {
	
	  0%,
	  100% {
	    opacity: 1;
	    transform: scale(1);
	  }
	
	  50% {
	    opacity: 0.5;
	    transform: scale(0.99);
	  }
	}
	
	/* Applying the animation */
	article.loading,
	article.loading img,
	article.loading h2,
	article.loading p {
	  animation: pulse 1.5s ease-in-out infinite;
	}

Finally we can setup the code for a simulated API call and HTML Patch operation. There are some concepts in this example from later modules, so don’t worry if you don’t recognise or understand all of the code below - we will highlight the important sections later on:

javascript
	function fakeApiCall() {
	  return {
	    // Fake article data
	    id: "XYZ-123",
	    image: "https://picsum.photos/800/200",
	    title: "Lorem ipsum dolor sit amet consectetur adipisicing elit.",
	    description: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum."
	  }
	}
	
	function stopLoading() {
	  const article = document.querySelector("article");
	  // Patch the article class to remove the loading class
	  article.classList.remove("loading");
	}
	
	function updateArticle(data) {
	  // Search the document for the root element we want to update
	  const article = document.querySelector("article");
	  // Search inside the article for the elements we want to update
	  const img = article.querySelector("img");
	  const h2 = article.querySelector("h2");
	  const p = article.querySelector("p");
	
	  // Patch each child element
	  img.src = data.image;
	  h2.textContent = data.title;
	  p.textContent = data.description;
	
	  // Patch the article id
	  article.id = data.id;
	
	  // Return a reference in case we need this later
	  return article;
	}
	
	async function example() {
	  const data = fakeApiCall();
	  const article = updateArticle(data);
	  console.log(article);
	  stopLoading();
	}
	
	function reset() {
	  const article = document.querySelector("article");
	  article.classList.add("loading");
	  article.id = "";
	  article.querySelector("img").src = "";
	  article.querySelector("h2").textContent = "";
	  article.querySelector("p").textContent = "";
	}

Let’s break down the code above into the key steps:

  1. We create a function fakeApiCall that returns a Promise that resolves after 2 seconds with some fake data. This is simply to simulate a real API call that takes some time to complete. This is not important for this lesson, but we will cover Promises in a later module.
  2. We create a function stopLoading that removes the loading class from the article element. This is important because the loading class is what triggers the pulsing animation. This is a quick and easy way to trigger animations on or off.
  3. We create a function updateArticle that takes some data and updates the HTML elements with the data. This is the key part of the patch operation. In this case we must select each element one by one and update the Element object reference one property at a time.
  4. We create a function example that calls the fakeApiCall function, waits for the Promise to resolve, then calls the stopLoading and updateArticle functions. This is the final step that ties everything together. We can think of this function as the “narrative” function that tells the user a story about how the app should behave.
  5. We create a function reset that resets the HTML elements back to their original state. Notice how we have to manually reset each property that we have touched. This means that as an application becomes more complicated and we touch more properties, we have to remember to reset each one.

In this case, the patch operation is quite straightforward - however as features become more ambitious, these patch operations can become large and hard to read.

Summary

Just like createElement, using the patch approach to dynamically updating HTML is:

  • Safe against XSS attacks
  • Robust and reliable
  • Highly performant
  • Not particularly convenient

This approach fits well with the createElement approach from the previous lesson, and forms this pattern:

  1. Create an element
  2. Populate the element with data
  3. Render the element to the page
  4. Patch the element as needed based on user interaction

Using this approach we are using a “light touch” on the DOM by changing only the required properties surgically, by targeting specific elements that need to change. This is a very efficient way to update the DOM, but can become hard to manage in complex applications.

Rerendering

Unlike the previous approach, where we delicately and surgically changed only the properties that needed to change, the rerender approach is more like a “sledgehammer” approach. When we need to update the HTML, we destroy the current HTML and completely recreate the new HTML with the updated information. This process usually happens so quickly that the user will not notice, and it appears to happen instantly. To use a real world analogy, we can think of this like buying a brand new replacement guitar when you need to change a string. It certainly gets the job done, but it’s not the most efficient approach.

Let’s look at a simple example of this approach:

html
	<p>Hello, world!</p>
javascript
	const p = document.querySelector('p');
	
	function update() {
	  document.body.innerHTML = '<p>Goodbye, world!</p>';
	}
	
	setTimeout(update, 2000);

In this example, we use innerHTML to completely redraw the entire page. It is not very realistic, however - since we would very rarely touch the entire page at once. Let’s look at a the same realistic example from above:

html
	<button onclick="example()">Finish loading</button>
	<button onclick="reset()">Reset Example</button>
	<div class="container">
	  <article class="loading" id="">
	    <img src="" alt="" />
	    <h2></h2>
	    <p></p>
	  </article>
	</div>
javascript
	function fakeApiCall() {
	  return {
	    // Fake article data
	    id: "XYZ-123",
	    image: "https://picsum.photos/800/200",
	    title: "Lorem ipsum dolor sit amet consectetur adipisicing elit.",
	    description: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum."
	  }
	}
	
	function articleTemplate(data, isLoading = false) {
	  return `
	  <article id="${data.id}" class="${isLoading ? 'loading' : ''}">
	    <img src="${data.image}" alt="" />
	    <h2>${data.title}</h2>
	    <p>${data.description}</p>
	  </article>
	`;
	}
	
	function updateArticle(data, isLoading = false) {
	  const article = articleTemplate(data, isLoading);
	  const container = document.querySelector(".container");
	  container.innerHTML = article;
	}
	
	function example() {
	  const data = fakeApiCall();
	  updateArticle(data);
	}
	
	function reset() {
	  updateArticle({ id: "", image: "", title: "", description: "" }, true);
	}

Let’s break down the code above into the key steps:

  1. We create a function articleTemplate that takes some data and returns a string of HTML. This is the key part of the rerender operation. In this case we use a template literal to create the HTML string, and we use the isLoading parameter to add or remove the loading class from the article element.
  2. We create a function updateArticle that takes some data and calls the articleTemplate function to create the HTML string, then uses innerHTML to replace the entire contents of the container element with the new HTML string.
  3. We create a function example that calls the fakeApiCall function, then calls the updateArticle function. This is the final step that ties everything together. We can think of this function as the “narrative” function that tells the user a story about how the app should behave.
  4. We create a function reset that calls the updateArticle function with some empty data and the isLoading parameter set to true. This is used to reset the state of the animation.

While the template function is much easier to read and understand, we end up needing additional code to handle fiddly tasks such as setting the loading class. As the code becomes more complicated and the number of unique properties increases, this approach may end up being more difficult to manage.

As mentioned earlier, this approach works well in conjunction with the innerHTML approach from the previous lesson, but does nothing to protect against the security issues:

  • Easy to read and understand
  • Quick to change or update templates
  • Not very efficient, since we are redrawing the entire page
  • Not as reliable due to the risk of malformed templates
  • Not safe against XSS attacks

Working with Arrays

When working with a list of items, such as an array of objects - additional considerations must be made. For example, if we are patching a list we must be sure that we are touching the correct item in the list to avoid misrepresenting the data to a user. For example, if we are working on a banking application and incorrectly select the wrong item when updating the amount of money showing in each account - the user may get a nasty surprise when they check their balance! A balance of $1m may show up as a balance of $0, depending on what the other items in the list contain!

Patching

Let’s take a look at a simple example of patching a list of items:

html
	<button onclick="sortByPrice()">Sort by price</button>
	<button onclick="reset()">Reset Example</button>
	
	<ul></ul>
javascript
	// Array of item objects, each with a name and price
	const items = [
	  { name: "Item 1", price: 100 },
	  { name: "Item 2", price: 299 },
	  { name: "Item 3", price: 30 },
	  { name: "Item 4", price: 1400 },
	  { name: "Item 5", price: 550 },
	];
	
	// Function to create a list item element for an item
	function createItem(itemData) {
	  const li = document.createElement("li"); // Create a new list item element
	  li.textContent = `${itemData.name} - $${itemData.price}`; // Set text content to item name and price
	  return li; // Return the created list item
	}
	
	// Function to create and display the entire list of items
	function createList() {
	  const list = document.querySelector("ul"); // Select the <ul> element
	  for (let i = 0; i < items.length; i++) { // Loop through all items
	    const itemData = items[i]; // Get data for current item
	    const listItem = createItem(itemData); // Create a list item for the current item
	    list.appendChild(listItem); // Add the list item to the <ul> element
	  }
	}
	
	// Function to update an individual list item element
	function updateItem(itemData, itemElement) {
	  itemElement.textContent = `${itemData.name} - $${itemData.price}`; // Update text content of the list item
	}
	
	// Function to update the entire list on the UI
	function updateList() {
	  const list = document.querySelector("ul"); // Select the <ul> element
	  const listItems = list.querySelectorAll("li"); // Select all list item elements
	
	  for (let i = 0; i < items.length; i++) { // Loop through all items
	    const itemData = items[i]; // Get data for current item
	    const listItem = listItems[i]; // Get corresponding list item element
	    updateItem(itemData, listItem); // Update the list item with new data
	  }
	}
	
	// Function to sort items by price and update the list
	function sortByPrice() {
	  items.sort((a, b) => a.price - b.price); // Sort items array by price in ascending order
	  updateList(); // Update the list to reflect sorted items
	}
	
	// Function to reset the list to its original order
	function reset() {
	  items.sort((a, b) => a.name.localeCompare(b.name)); // Sort items array by name in alphabetical order
	  updateList(); // Update the list to reflect the original order
	}
	
	createList(); // Initially create and display the list when the script loads

If you read this and feel overwhelmed, don’t worry. This is a lot of code for quite a simple task! In order to patch this list, we need a lot of extra code to make sure that everything goes smoothly. Let’s break down the code above into the key steps:

  1. We create a function createItem that takes some data and returns a new li element. This is different from the updateItem function, which takes an existing li element and updates it’s properties.
  2. We create a function createList that selects the ul element and appends each item to the list. This is needed to setup the initial conditions for the application.
  3. We create a function updateItem that takes some data and an existing li element and updates the properties of the li element. This is where we can make our changes to the list.
  4. We create a function updateList that selects the ul element and each li element, then calls the updateItem function for each item in the list. This is where we can control which content goes into which list item. If there are issues with the order of items, it will most likely be due to this function.
  5. We create a function sortByPrice that sorts the items array by price, then calls the updateList function. Since the array has been reordered, we need to update the list to reflect the new order.
  6. We create a function reset that sorts the items array by name, then calls the updateList function.

Note how in this example every change requires two steps:

  1. Update the array data
  2. Render the updated list items

Just because the underlying array data has changed, does not mean that the list will automatically update.

Rerendering

Compared with the approach above, the rerender approach is more straightforward. We can easily reuse the functions for creating each list item to completely destroy and recreate the list with a new sort order.

html
	<button onclick="sortByPrice()">Sort by price</button>
	<button onclick="reset()">Reset Example</button>
	
	<ul></ul>
javascript
	// Array of item objects, each with a name and price
	const items = [
	  { name: "Item 1", price: 100 },
	  { name: "Item 2", price: 299 },
	  { name: "Item 3", price: 30 },
	  { name: "Item 4", price: 1400 },
	  { name: "Item 5", price: 550 },
	];
	
	// Function to create a list item element for an item
	function itemTemplate(item) {
	  return `<li>${item.name} - $${item.price}</li>`;
	}
	
	// Function to create and display the entire list of items
	function updateList() {
	  const list = document.querySelector("ul");
	  list.innerHTML = items.map(itemTemplate).join("");
	}
	
	// Function to sort items by price and update the list
	function sortByPrice() {
	  items.sort((a, b) => a.price - b.price);
	  updateList();
	}
	
	// Function to reset the list to its original order
	function reset() {
	  items.sort((a, b) => a.name.localeCompare(b.name));
	  updateList();
	}
	
	updateList() // Initially create and display the list when the script loads

In this example the approach taken is to “destroy” and recreate the entire list each time the list needs to be updated. This has the benefit of being quite straightforward as there is not the same risk of getting the index of the item wrong and patching the wrong item.

Let’s break down the code above into the key steps:

  1. We create a function itemTemplate that takes an item and returns a string of HTML.
  2. We create a function updateList that selects the ul element and uses innerHTML to replace the entire contents of the ul element with the new HTML string.
  3. We create a function sortByPrice that sorts the items array by price, then calls the updateList function.
  4. We create a function reset that sorts the items array by name, then calls the updateList function.
  5. We call the updateList function to initially create and display the list when the script loads.

In both examples, we need to update the list data in JavaScript before we show these changes to the user. Only when working with a framework will you enjoy “automatic” render updates when data changes. This is referred to as Reactivity and will be covered in much more detail later in the program.

Summary

Ideally, we want to combine the best of both approaches into a usable approach that:

  1. Avoids the security concerns from the use of innerHTML
  2. Easy to read, follow and understand
  3. Accurate array updates and list rendering

We can achieve this by using the Rerender approach in combination with document.createElement:

javascript
	// Sample data in an array
	const items = [1, 2, 3, 4];
	
	// Function to create a list item element for an item
	function createItem(item) {
	  const li = document.createElement("li");
	  li.textContent = item;
	  return li;
	}
	
	// Function to create the entire list of items
	function createItems(items) {
	  return items.map(createItem);
	}
	
	// Function to update the entire list on the UI
	function updateList(items) {
	  const list = document.querySelector("ul");
	  const listItems = createItems(items);
	  list.innerHTML = "";
	  list.append(...listItems);
	}

In this example, we are clearing the ul element each time an update is needed. However, instead of using innerHTML in order to show the HTML on screen, we use the safer and more robust createElement approach.

Memory Leaks

Although the example above is perfectly usable, if we introduce event listeners for each li item - we could quickly run into an issue. Let’s update the example to add these event listeners:

javascript
	// Sample data in an array
	const items = [1, 2, 3, 4];
	
	// Function to create a list item element for an item
	function createItem(item) {
	  const li = document.createElement("li");
	  li.textContent = item;
	  li.addEventListener("click", () => alert(`You clicked item ${item}`));
	  return li;
	}
	
	// Function to create the entire list of items
	function createItems(items) {
	  return items.map(createItem);
	}
	
	// Function to update the entire list on the UI
	function updateList(items) {
	  const list = document.querySelector("ul");
	  const listItems = createItems(items);
	  list.innerHTML = "";
	  list.append(...listItems);
	}

If we run this example over and over, updating the list hundreds of times, we will notice that the number of event listeners goes up each time the list is updated. It never goes down, even when we clear the list. This is because innerHTML = "" does not remove event listeners automatically. If we continue updating the list, eventually the computer memory will become full and the browser tab will crash. This is referred to as a Memory Leak and is a common issue when working with JavaScript.

Avoiding Memory Leaks

Programming to avoid this issue is a difficult skill that comes with experience. It may seem like a flaw or bug in JavaScript - but this is a concern in many other languages too. In order to avoid this situation - we need to remove each event listener before the item is destroyed and recreated. There are two ways to do this:

  1. Use removeEventListener to remove the event listener before the item is destroyed
  2. Use element.remove() to remove the element from the DOM, which will also remove the event listener

Let’s look at each of these approaches:

Using removeEventListener
javascript
	// Sample data in an array
	const items = [1, 2, 3, 4];
	
	// Event handler
	function handleClick(item) {
	  alert(`You clicked item ${item}`);
	}
	
	// Function to create a list item element for an item
	function createItem(item) {
	  const li = document.createElement("li");
	  li.textContent = item;
	  li.addEventListener("click", handleClick);
	  return li;
	}
	
	// Function to create the entire list of items
	function createItems(items) {
	  return items.map(createItem);
	}
	
	// Function to update the entire list on the UI
	function updateList(items) {
	  const list = document.querySelector("ul");
	  const listItems = createItems(items);
	
	  // Remove event listeners from existing list items
	  list.querySelectorAll("li").forEach((li) => {
	    li.removeEventListener("click", handleClick);
	  });
	
	  list.innerHTML = "";
	  list.append(...listItems);
	}

While this approach is quite understandable, it also adds extra lines of code and forces us to keep track of each event handler in order to remove it later. This can become quite difficult to manage in larger applications.

Using element.remove()
javascript
	// Sample data in an array
	const items = [1, 2, 3, 4];
	
	// Function to create a list item element for an item
	function createItem(item) {
	  const li = document.createElement("li");
	  li.textContent = item;
	  li.addEventListener("click", () => alert(`You clicked item ${item}`));
	  return li;
	}
	
	// Function to create the entire list of items
	function createItems(items) {
	  return items.map(createItem);
	}
	
	// Function to update the entire list on the UI
	function updateList(items) {
	  const list = document.querySelector("ul");
	  const listItems = createItems(items);
	
	  // Remove existing list items from the DOM
	  list.querySelectorAll("li").forEach((li) => {
	    li.remove();
	  });
	
	  list.append(...listItems);
	}

In this example, instead of resetting the ul element with innerHTML = "", we instead remove each li element from the DOM using li.remove(). This has the added benefit of removing the event listener automatically, since the element is removed from the DOM. Effectively we are taking care of two jobs at once, cleaning up old event listeners and resetting the list. Let’s look at one more trick to make this process even more smooth:

javascript
	// This will add a new method to every HTML element
	HTMLElement.prototype.clear = function() {
	  while (this.firstChild) {
	    this.firstChild.remove();
	  }
	};
	
	const list = document.querySelector("ul");
	list.clear();

Let’s put it all together:

javascript
	// Sample data in an array
	const items = [1, 2, 3, 4];
	
	// Function to create a list item element for an item
	function createItem(item) {
	  const li = document.createElement("li");
	  li.textContent = item;
	  li.addEventListener("click", () => alert(`You clicked item ${item}`));
	  return li;
	}
	
	// Function to create the entire list of items
	function createItems(items) {
	  return items.map(createItem);
	}
	
	// Function to update the entire list on the UI
	function updateList(items) {
	  const list = document.querySelector("ul");
	  const listItems = createItems(items);
	
	  // Remove existing list items from the DOM
	  list.clear();
	
	  list.append(...listItems);
	}

Now we can safely rerender a list of items without any concerns about memory leaks or cross-site scripting attacks!

Lesson Task

Brief

Create a working To-Do list application that allows for items to be added and removed from the list.

Process

  1. Create an HTML file called todo.html.
  2. Create a JavaScript file called todo.js.
  3. Link the JavaScript file to the HTML file.
  4. Create a ul element with an id of todo-list.
  5. Create a form with name todo and an input with name todo.
  6. Add a submit event listener to the form.
  7. When the form is submitted, add the value of the input to an array called todos.
  8. When the array is updated, update the list of items on the page.
  9. Add a click event listener to each list item.
  10. When a list item is clicked, remove the item from the array and update the list of items on the page.

Solution

You can find an interactive solution here.