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 propertiesRerender
- 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:
- Patch the socks with thread and fabric
- 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:
<p>Hello, world!</p>
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:
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.
<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:
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:
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:
- We create a function
fakeApiCall
that returns aPromise
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. - We create a function
stopLoading
that removes theloading
class from thearticle
element. This is important because theloading
class is what triggers the pulsing animation. This is a quick and easy way to trigger animations on or off. - 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 theElement object reference
one property at a time. - We create a function
example
that calls thefakeApiCall
function, waits for thePromise
to resolve, then calls thestopLoading
andupdateArticle
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. - 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:
- Create an element
- Populate the element with data
- Render the element to the page
- 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:
<p>Hello, world!</p>
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:
<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>
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:
- 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 theisLoading
parameter to add or remove theloading
class from thearticle
element. - We create a function
updateArticle
that takes some data and calls thearticleTemplate
function to create the HTML string, then usesinnerHTML
to replace the entire contents of thecontainer
element with the new HTML string. - We create a function
example
that calls thefakeApiCall
function, then calls theupdateArticle
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. - We create a function
reset
that calls theupdateArticle
function with some empty data and theisLoading
parameter set totrue
. 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:
<button onclick="sortByPrice()">Sort by price</button>
<button onclick="reset()">Reset Example</button>
<ul></ul>
// 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:
- We create a function
createItem
that takes some data and returns a newli
element. This is different from theupdateItem
function, which takes an existingli
element and updates it’s properties. - We create a function
createList
that selects theul
element and appends each item to the list. This is needed to setup the initial conditions for the application. - We create a function
updateItem
that takes some data and an existingli
element and updates the properties of theli
element. This is where we can make our changes to the list. - We create a function
updateList
that selects theul
element and eachli
element, then calls theupdateItem
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. - We create a function
sortByPrice
that sorts theitems
array by price, then calls theupdateList
function. Since the array has been reordered, we need to update the list to reflect the new order. - We create a function
reset
that sorts theitems
array by name, then calls theupdateList
function.
Note how in this example every change requires two steps:
- Update the array data
- 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.
<button onclick="sortByPrice()">Sort by price</button>
<button onclick="reset()">Reset Example</button>
<ul></ul>
// 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:
- We create a function
itemTemplate
that takes an item and returns a string of HTML. - We create a function
updateList
that selects theul
element and usesinnerHTML
to replace the entire contents of theul
element with the new HTML string. - We create a function
sortByPrice
that sorts theitems
array by price, then calls theupdateList
function. - We create a function
reset
that sorts theitems
array by name, then calls theupdateList
function. - 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:
- Avoids the security concerns from the use of
innerHTML
- Easy to read, follow and understand
- Accurate array updates and list rendering
We can achieve this by using the Rerender
approach in combination with document.createElement
:
// 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:
// 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:
- Use
removeEventListener
to remove the event listener before the item is destroyed - 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
// 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()
// 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:
// 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:
// 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
- Create an HTML file called
todo.html
. - Create a JavaScript file called
todo.js
. - Link the JavaScript file to the HTML file.
- Create a
ul
element with anid
oftodo-list
. - Create a form with name
todo
and an input with nametodo
. - Add a
submit
event listener to the form. - When the form is submitted, add the value of the input to an array called
todos
. - When the array is updated, update the list of items on the page.
- Add a
click
event listener to each list item. - 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.