A list from data
In short: Rendering from data is one render() function that builds the list on the page from an array, so that every place shows the same thing.
Problem
The cart lies
A buyer adds bread and milk. The lines in the cart are right, the total is right, and the top says "Cart (0)". Press both "Add" buttons and look at the badge.
Console
The console is empty. Every place on the page is updated by a separate line of code in add, and there is no such line for the badge. The browser does not know that the three places show the same thing. So nobody notices the mistake, except the buyer.
The idea
From an array to a list
The products come as data: an array of objects with a name and a price. The page has to show them as list items. The path is the same for every list:
function productItem(product) { const li = document.createElement('li'); li.textContent = `${product.name}, ${money(product.price)}`; return li;}
list.replaceChildren(...products.map(productItem));document.createElement('li')creates a new element. It is not on the page yet.textContentsets the text. The name of the product is text, not HTML, so no character in it becomes a tag.products.map(productItem)makes one<li>from each product: an array of elements.list.replaceChildren(...)removes the old children of the list and puts in the new ones....spreads the array into separate arguments.
This is called rendering a list from data: the data live in JavaScript, and the page is built from them by a function.
See it
The catalogue
Console
Change the price of the bread in the array and press "Run". You do not touch the HTML: the list follows the data. Add a fifth product to the array and it shows up as a fifth line.
Try it
Only what is in stock
Show in the list only the products that are in stock (stock is greater than 0). Below the list, in #count, write how many there are: "In stock: 3 of 5".
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Bread and milk</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<main>
<h1>Bread and milk</h1>
<ul id="products"></ul>
<p id="count"></p>
</main>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
li { margin: 4px 0; }
button { font: inherit; margin-left: 8px; }
script.jsconst products = [
{ id: 1, name: 'White bread', price: 1.2, stock: 40 },
{ id: 4, name: 'Cheese banitsa', price: 2.5, stock: 0 },
{ id: 5, name: 'Fresh milk', price: 1.6, stock: 35 },
{ id: 13, name: 'Cucumbers', price: 2.8, stock: 0 },
{ id: 10, name: 'Apples', price: 2.1, stock: 80 },
];
const money = (x) => '€' + x.toFixed(2);
function productItem(product) {
const li = document.createElement('li');
li.textContent = `${product.name}, ${money(product.price)}`;
return li;
}
const list = document.querySelector('#products');
const count = document.querySelector('#count');
const inStock = products.filter((p) => p.stock > 0);
list.replaceChildren(...inStock.map(productItem));
count.textContent = `In stock: ${inStock.length} of ${products.length}`;
products.filter((p) => p.stock > 0) is a new array with only the products in stock. The list is made from it, and the lengths of the two arrays give the two numbers. The array products stays whole: the filter does not change it.
The idea
One set of data, three places
The cart is an array cart. The page shows it three times: the count at the top, the lines and the total. Every time cart changes, the three places have to change together.
By hand this means: every function that changes cart touches every place that shows it. add touches the lines and the total. remove touches the lines, the total and the badge. Tomorrow a fourth place comes, say "Free delivery over €30", and every function has to remember it.
When one place is forgotten, the data and the screen drift apart: cart holds two products and the page says zero. There is no error. There is a page that shows something false.
See it
Watch them drift apart
const cart = [];
function addByHand(product) { cart.push(product); cartList.append(cartItem(product)); total.textContent = `Total: ${money(sum())}`;}
function render() { badge.textContent = `Cart (${cart.length})`; cartList.replaceChildren(...cart.map(cartItem)); total.textContent = `Total: ${money(sum())}`;}
function add(product) { cart.push(product); render();}Step by step, in words
- On the left are the data: the array cart. On the right are the three places on the page that show it: the badge, the lines and the total.
- addByHand(bread). First the data change: the array has one product.
- Then the function touches the screen by hand: a new line and a new total. Every place is a separate line of code.
- Nobody wrote a line for the badge. The data say 1, the badge says 0. There is no error in the console: the page simply shows something false.
- One more product, by hand again. The gap grows: 2 against 0.
- Now add(): it only changes the data. For a moment the whole screen is behind, because nothing has been drawn yet.
- render() draws all three places again from cart: the badge, every line and the total. It does not care what was on the screen before.
- The rule: every change is the data first, then render(). The screen follows from the data, and a forgotten place is impossible.
addByHand touches two places out of three. render touches all three, every time, from the data. It does not ask what was on the screen before, so it cannot be misled by it.
The idea
One render()
The rule is short: the functions that change the data do not touch the screen. They change the data and call render(). Only render() writes to the page, and it writes everything from the data:
function render() { badge.textContent = `Cart (${cart.length})`; cartList.replaceChildren(...cart.map(cartItem)); total.textContent = `Total: ${money(sum())}`;}This way every place is described once. A new place is one line in render(), and it works for add, for remove and for every future function. The lines are drawn again in full: replaceChildren removes the old ones and puts new ones from cart.map(cartItem).
Try it
One render()
add and remove touch the screen by hand, and each forgets one place. Write render() so that it draws the badge, the lines and the total from cart, and make add and remove only change cart and call render().
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Cart</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<header>
<a href="/">The shop</a>
<a href="/cart" id="badge">Cart (0)</a>
</header>
<main>
<h1>Bread and milk</h1>
<ul id="products"></ul>
<h2>The cart</h2>
<ul id="cart"></ul>
<p id="total">Total: €0.00</p>
</main>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
li { margin: 4px 0; }
button { font: inherit; margin-left: 8px; }
script.jsconst products = [
{ id: 1, name: 'White bread', price: 1.2 },
{ id: 5, name: 'Fresh milk', price: 1.6 },
{ id: 7, name: 'White cheese', price: 7.9 },
{ id: 10, name: 'Apples', price: 2.1 },
];
const cart = [];
const money = (x) => '€' + x.toFixed(2);
const badge = document.querySelector('#badge');
const cartList = document.querySelector('#cart');
const total = document.querySelector('#total');
const sum = () => cart.reduce((s, p) => s + p.price, 0);
function productItem(product) {
const li = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Add';
button.setAttribute('aria-label', `Add ${product.name}`);
button.addEventListener('click', () => add(product));
li.append(`${product.name}, ${money(product.price)}`, button);
return li;
}
document.querySelector('#products').replaceChildren(...products.map(productItem));
function cartItem(product, index) {
const li = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Remove';
button.setAttribute('aria-label', `Remove ${product.name}`);
button.addEventListener('click', () => remove(index));
li.append(product.name, button);
return li;
}
function add(product) {
cart.push(product);
render();
}
function remove(index) {
cart.splice(index, 1);
render();
}
function render() {
badge.textContent = `Cart (${cart.length})`;
cartList.replaceChildren(...cart.map(cartItem));
total.textContent = `Total: ${money(sum())}`;
}
add does cart.push(product) and render(). remove does cart.splice(index, 1) and render(). render() writes the badge, replaces all lines with replaceChildren and writes the total. If the lines are added with append without removing the old ones, three clicks give six lines: 1 + 2 + 3.
Try it
Find the bug
The badge is always one product behind: after the first click it says 0, after the second 1.
Pick the line with the bug.
render() is on line 2, before cart.push(product). It draws the data as they are at that moment: without the new product. First the data change, then you draw.
The idea
The price of "everything again"
render() does not know what changed, so it replaces everything. Usually you cannot see that. Sometimes you can. In the cart below every line has a quantity field, and every change of the field calls render(). The buyer wants 15 milks: deletes the 2, types 1 and 5.
Console
After the digit 1, render() removes the old field and puts a new one with the value 1. The focus was on the old field and it is gone, so the 5 goes nowhere. The same happens to the caret in a text field, to the scroll position in a long list and to an open drop-down. And with 1000 lines the browser builds 1000 new elements on every click.
Both ways are bad: by hand you forget places, and "everything again" loses focus and is slow for a big list. The answer is a third way: you write one function that says what the screen looks like for these data, like render(). Something else compares the new result with the old one and changes only the difference on the page: the text of the badge, one line, nothing about the field. This is what React and Vue do, and it is the next module: components.
More precisely, if you are curious
innerHTML instead of createElement. list.innerHTML = products.map((p) => `<li>${p.name}</li>`).join('') is shorter, but the name of the product goes in as HTML. A name with <img onerror=…> becomes code on the page (XSS). textContent and createElement keep text as text.
replaceChildren. With no arguments it empties the element: list.replaceChildren(). Before it, people wrote list.innerHTML = '' and then append.
The listeners. Every new button gets a new listener in productItem. The old buttons disappear together with their listeners. Another way is delegation: one listener on the <ul> and event.target.closest('li'), with the element saying which product it is (data-id).
hidden. element.hidden = true is like the hidden attribute in HTML: the element is not shown. If a CSS rule gives the element a display, the rule wins over hidden and the element shows again.
Keeping the focus by hand. You can remember which field had the focus and where the caret was, and put them back after render(). It works, but it is one more place to forget. The libraries do it for you, because they do not replace the field at all.
Exercise
The cart
Build the catalogue and the cart from the data. Every product is a line with an "Add" button (the name for a screen reader: "Add White bread"). Every line in the cart has a "Remove" button ("Remove White bread"). The badge at the top is "Cart (2)", the total is "Total: €10.00", and "The cart is empty" shows only when the cart is empty (the hidden property).
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Cart</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<header>
<a href="/">The shop</a>
<a href="/cart" id="badge">Cart (0)</a>
</header>
<main>
<h1>Bread and milk</h1>
<ul id="products"></ul>
<h2>The cart</h2>
<p id="empty">The cart is empty</p>
<ul id="cart"></ul>
<p id="total">Total: €0.00</p>
</main>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
li { margin: 4px 0; }
button { font: inherit; margin-left: 8px; }
script.jsconst products = [
{ id: 1, name: 'White bread', price: 1.2 },
{ id: 5, name: 'Fresh milk', price: 1.6 },
{ id: 7, name: 'White cheese', price: 7.9 },
{ id: 10, name: 'Apples', price: 2.1 },
];
const cart = [];
const money = (x) => '€' + x.toFixed(2);
const badge = document.querySelector('#badge');
const cartList = document.querySelector('#cart');
const total = document.querySelector('#total');
const empty = document.querySelector('#empty');
const sum = () => cart.reduce((s, p) => s + p.price, 0);
function productItem(product) {
const li = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Add';
button.setAttribute('aria-label', `Add ${product.name}`);
button.addEventListener('click', () => add(product));
li.append(`${product.name}, ${money(product.price)}`, button);
return li;
}
function cartItem(product, index) {
const li = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Remove';
button.setAttribute('aria-label', `Remove ${product.name}`);
button.addEventListener('click', () => remove(index));
li.append(product.name, button);
return li;
}
function add(product) {
cart.push(product);
render();
}
function remove(index) {
cart.splice(index, 1);
render();
}
function render() {
badge.textContent = `Cart (${cart.length})`;
empty.hidden = cart.length > 0;
cartList.replaceChildren(...cart.map(cartItem));
total.textContent = `Total: ${money(sum())}`;
}
document.querySelector('#products').replaceChildren(...products.map(productItem));
render();
The answer has one render(): the badge, empty.hidden = cart.length > 0, the lines with replaceChildren and the total. add and remove only change cart and call render(). At the bottom render() is called once on load too, so the page is right before the first click. The check adds two products, removes them and checks that the empty-cart message comes back.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Text, links, images, tables · Accessibility · The DOM: the page from JavaScript · Events
- Continues in: Data from an API · The first component · Props: data from above · Lists and keys
- Tasks on this topic: JavaScript in the page