Data from an API
In short: fetch gets data from an API with async/await, checks response.ok and reads the JSON, and the page shows one of four states: loading, error, empty or data.
Problem
An empty shop window
The dairy page takes its products from the server. Today the server has a problem and answers with error 500. The buyer sees a heading and nothing under it.
Console
The console says products.map is not a function. The code took the answer of the error, { "error": "…" }, for a list of products. The buyer sees no error and no "try again". And even when the server works, the page is blank while the answer is on its way.
The idea
fetch on the page
fetch(address) sends a request and returns a promise (Promise) of the response. With await the function waits for the response without stopping the page:
const response = await fetch('/api/products?category=4');if (!response.ok) throw new Error(`HTTP ${response.status}`);const products = await response.json();fetchthrows only when there is no answer: no network. A response with 404 or 500 is an answer, and the promise is fulfilled.response.okis true only for codes from 200 to 299. Checking it is your job.response.json()reads the body and turns it into an object. This is a promise too, so it also getsawait.
In the sandbox the addresses /api/… are answered by the shop's pretend API, with no network: GET /api/products (with ?q= and ?category=), /api/products/3, /api/categories, /api/orders and POST /api/orders. A lesson can make it slow, tell it to answer with 500 or make it return an empty list. This way every state is real and always the same.
The idea
Four states
A request takes time and has an outcome. The screen has to say what is going on at every moment:
- loading: the request has left, there is no answer;
- data: a list of products has come;
- empty: a list has come, but it is empty. This is not an error;
- error: there is no answer, or the answer is not
ok.
Every state is an element in the HTML. The code shows and hides the elements with the hidden property:
<p id="loading" role="status">Loading…</p><p id="error" role="alert" hidden></p><p id="empty" hidden>There are no products in this category.</p><ul id="products"></ul>"Loading…" is visible from the very start, before JavaScript even runs. role="status" makes a screen reader read it, and role="alert" reads the error as soon as it appears.
See it
Watch one request
async function load() { try { const response = await fetch('/api/products?category=4'); if (!response.ok) throw new Error(`HTTP ${response.status}`); const products = await response.json(); empty.hidden = products.length > 0; list.replaceChildren(...products.map(productItem)); } catch { error.textContent = 'The products did not load.'; error.hidden = false; } finally { loading.hidden = true; }}Step by step, in words
- load() sends the request. There is no answer yet: the screen shows "Loading…" from the HTML.
- await waits, but the page does not freeze: the buyer can scroll and the spinner turns. On a slow network this takes seconds.
- 200 with an array: ok is true, json() gives the products, the lines are drawn, and finally hides "Loading…".
- The same request, answer 500. fetch does not throw: it got an answer. Our check of ok throws, and catch shows the error.
- 200 with []: this is not an error. The array is empty, so the page says "no products" instead of showing a blank space.
- Four states, each visible: loading, data, error, empty. finally hides "Loading…" on every way out of try.
The same code, three different answers. With 500 fetch does not throw: the check of ok throws, and catch shows the error. finally hides "Loading…" in all three cases.
See it
The slow API
Here the API answers after 1.2 seconds. Press "Run" and watch "Loading…".
Console
The whole loading code:
async function load() { try { const response = await fetch('/api/products?category=4'); if (!response.ok) throw new Error(`HTTP ${response.status}`); const products = await response.json(); empty.hidden = products.length > 0; list.replaceChildren(...products.map(productItem)); } catch { error.textContent = 'The products did not load.'; error.hidden = false; } finally { loading.hidden = true; }}
load();Try it
Error
Here the first request gets a 500 and the second one goes through. On an error, make the page show "The products did not load." in #error and the "Try again" button, make "Loading…" disappear, and do not show "no products". The button already loads again: after it the products must be on the page.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dairy</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Dairy</h1>
<p id="loading" role="status">Loading…</p>
<p id="error" role="alert" hidden></p>
<button id="retry" hidden>Try again</button>
<p id="empty" hidden>There are no products in this category.</p>
<ul id="products"></ul>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0 16px; }
li { margin: 4px 0; }
#error { color: #b3261e; }
label { display: block; margin-top: 8px; }
.error { color: #b3261e; margin: 2px 0 0; min-height: 1.2em; }
button { font: inherit; margin-top: 12px; }
script.jsconst loading = document.querySelector('#loading');
const error = document.querySelector('#error');
const empty = document.querySelector('#empty');
const list = document.querySelector('#products');
const money = (x) => '€' + x.toFixed(2);
function productItem(product) {
const li = document.createElement('li');
li.textContent = `${product.name}, ${money(product.price)}`;
return li;
}
const retry = document.querySelector('#retry');
retry.addEventListener('click', () => {
retry.hidden = true;
error.hidden = true;
loading.hidden = false;
load();
});
async function load() {
try {
const response = await fetch('/api/products?category=4');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const products = await response.json();
empty.hidden = products.length > 0;
list.replaceChildren(...products.map(productItem));
} catch {
error.textContent = 'The products did not load.';
error.hidden = false;
retry.hidden = false;
} finally {
loading.hidden = true;
}
}
load();
The check of response.ok throws on 500, catch writes the message and shows #error and the button, and finally hides "Loading…". Without the check the code reads { "error": "…" } as products: products.length is undefined, and undefined > 0 is false, so "no products" is shown, which is false. The check presses "Try again", because a page that always shows the error would pass the first half too.
Try it
Empty
Here the API returns an empty list. Show "There are no products in this category." when there are zero products, and hide it when there are some.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dairy</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Dairy</h1>
<p id="loading" role="status">Loading…</p>
<p id="error" role="alert" hidden></p>
<p id="empty" hidden>There are no products in this category.</p>
<ul id="products"></ul>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0 16px; }
li { margin: 4px 0; }
#error { color: #b3261e; }
label { display: block; margin-top: 8px; }
.error { color: #b3261e; margin: 2px 0 0; min-height: 1.2em; }
button { font: inherit; margin-top: 12px; }
script.jsconst loading = document.querySelector('#loading');
const error = document.querySelector('#error');
const empty = document.querySelector('#empty');
const list = document.querySelector('#products');
const money = (x) => '€' + x.toFixed(2);
function productItem(product) {
const li = document.createElement('li');
li.textContent = `${product.name}, ${money(product.price)}`;
return li;
}
async function load() {
try {
const response = await fetch('/api/products?category=4');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const products = await response.json();
empty.hidden = products.length > 0;
list.replaceChildren(...products.map(productItem));
} catch {
error.textContent = 'The products did not load.';
error.hidden = false;
} finally {
loading.hidden = true;
}
}
load();
empty.hidden = products.length > 0;: the message is hidden exactly when there is at least one product. if (!products) does not work: an empty array is "true", and !products is false.
Try it
Find the bug
The server answers with 200 and products, and the page shows an error.
Pick the line with the bug.
Line 5 is missing await. response.json() returns a promise, not an array. A promise has no map, so line 6 throws and catch shows an error, although the server did answer.
The idea
Sending: the button and the wait
The order leaves with POST. The data are in the body as JSON, and the Content-Type header tells the server what it gets:
const response = await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...data, items }),});data is the object from the fields of the form: Object.fromEntries(new FormData(form)) turns the name=value pairs into an object { name: …, email: …, address: … }.
While the answer is on its way, the "Place order" button is still enabled. An impatient buyer presses twice:
Console
Two requests, two orders: 61 and 62. The page shows the last one, and nobody notices that the first one was accepted too.
Try it
One click, one order
Disable the "Place order" button before the request leaves, and enable it again when the answer comes, even if it is an error. The check presses twice quickly and expects one order: 61.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Quick order</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Quick order</h1>
<p>White bread × 2, Fresh milk × 1</p>
<form id="order">
<label>Name <input name="name" value="Iva Petrova"></label>
<label>Email <input name="email" value="iva@example.com"></label>
<label>Address <input name="address" value="7 Shipka St"></label>
<button>Place order</button>
</form>
<p id="result" role="status"></p>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0 16px; }
li { margin: 4px 0; }
#error { color: #b3261e; }
label { display: block; margin-top: 8px; }
.error { color: #b3261e; margin: 2px 0 0; min-height: 1.2em; }
button { font: inherit; margin-top: 12px; }
script.jsconst items = [{ productId: 1, qty: 2 }, { productId: 5, qty: 1 }];
const form = document.querySelector('#order');
const button = form.querySelector('button');
const result = document.querySelector('#result');
form.addEventListener('submit', async (event) => {
event.preventDefault();
button.disabled = true;
try {
const data = Object.fromEntries(new FormData(form));
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, items }),
});
const order = await response.json();
result.textContent = `Order ${order.id} is accepted.`;
} finally {
button.disabled = false;
}
});
button.disabled = true is the first thing after preventDefault(), before await. A disabled button takes no click and does not send the form. finally { button.disabled = false; } enables it after success and after an error. If the button is disabled after await fetch(…), the second click has already gone through.
More precisely, if you are curious
A network error. With no network fetch throws TypeError: Failed to fetch. catch catches that and your own throw on !response.ok: for the buyer both mean "did not load".
The body of an error. A server often explains the error in the body, as JSON. On 400 the pretend API returns { "errors": { "email": "…" } }: an error per field. That is why the exercise reads the body on an error too.
Try again. A "Try again" button next to the error just calls load() again. Before that, "Loading…" has to show again and the error has to hide.
A race between requests. If the buyer changes the category twice quickly, the two answers may come in reverse order and the page shows the old category. How to stop an old request (AbortController) is in the module on effects.
Checks in the browser and on the server. required and type="email" stop the form before it leaves. The server checks again, because a request can come without our page. In the exercise the fields have no built-in checks, so you can see what the server answers.
Exercise
The order
Send the order: POST /api/orders with name, email and address from the form and items from the array. While it is being sent, the button is disabled. On 400 put every message from errors into the paragraph under its field (#email-error and so on), and clear the old messages on every new send. On success write "Order 61 is accepted. Total: €4.00" in #result (the number and the total come from the answer).
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Checkout</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Checkout</h1>
<p>White bread × 2, Fresh milk × 1</p>
<p class="error" id="items-error"></p>
<form id="order">
<label>Name <input name="name"></label>
<p class="error" id="name-error"></p>
<label>Email <input name="email"></label>
<p class="error" id="email-error"></p>
<label>Address <input name="address"></label>
<p class="error" id="address-error"></p>
<button>Place order</button>
</form>
<p id="result" role="status"></p>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0 16px; }
li { margin: 4px 0; }
#error { color: #b3261e; }
label { display: block; margin-top: 8px; }
.error { color: #b3261e; margin: 2px 0 0; min-height: 1.2em; }
button { font: inherit; margin-top: 12px; }
script.jsconst items = [{ productId: 1, qty: 2 }, { productId: 5, qty: 1 }];
const form = document.querySelector('#order');
const button = form.querySelector('button');
const result = document.querySelector('#result');
const money = (x) => '€' + x.toFixed(2);
form.addEventListener('submit', async (event) => {
event.preventDefault();
button.disabled = true;
result.textContent = '';
for (const p of document.querySelectorAll('.error')) p.textContent = '';
try {
const data = Object.fromEntries(new FormData(form));
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, items }),
});
const answer = await response.json();
if (response.status === 400) {
for (const [name, message] of Object.entries(answer.errors)) {
document.querySelector(`#${name}-error`).textContent = message;
}
} else if (response.ok) {
result.textContent = `Order ${answer.id} is accepted. Total: ${money(answer.total)}`;
} else {
result.textContent = 'The order was not sent. Try again.';
}
} finally {
button.disabled = false;
}
});
The server returns 400 and { errors: { email: …, address: … } }. The loop over Object.entries(answer.errors) finds the paragraph #${name}-error and writes the message. If the old messages are not cleared, after the fix "The email is not valid" stays under a correct field. On 201 the answer is { id, status, total }: the number and the total come from the server, not from the page.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Accessibility · The DOM: the page from JavaScript · Events · A list from data
- Continues in: Conditions: empty, error, loading · Data from an API in a component · Forms in a component · The shop front
- Tasks on this topic: JavaScript in the page