# Loading, Empty and Error States

A page that loads data has four states, loading, success, empty and error, and it should say which one it's in, so people never stare at a blank screen or a page that quietly broke.

Journeyman · JavaScript · REST API · Web Development · Updated November 1, 2026

## After this lesson you can

- Show a loading state while a request runs, then replace it with the data, an empty message or an error message
- Add a Try again button that stays hidden until a request fails and runs the same request again

## Key takeaways

- A page that loads data is always in one of four states, loading, success, empty or error, and it should show which one.
- Show the loading state before the request starts, and disable the button so a second click can't send a second request.
- An empty answer isn't an error. Say there's nothing to show and what the person can try instead.
- An error message says what happened in plain words and what to do next, and a Try again button runs the same request again.
- Put the clean-up that must always happen, such as enabling the button again, in finally.

Search a store's site for something it doesn't sell and you get *No results for "purple waffle iron". Check the spelling or try a shorter search.* Open a food delivery app on a slow train and you see gray boxes where the restaurants will be. Lose your signal and a message says *You're offline* with a **Retry** button. None of those screens show data, and every one of them was designed on purpose. You'll write them whenever a page loads data, and they're what a person notices first when something is slow or wrong.

*When fetch Fails* taught your code to catch every way a request goes wrong. This lesson is about what the **page** shows while it waits, and after.

## Four things the page can be doing

When you order food in an app, it always tells you where your order is: *Order received*, *Being prepared*, *On its way*, or *Sorry, the restaurant is closed*. You're never left guessing.

A page that loads data has four states like that, and it should always show which one it's in:

| State | What happened | What the shelter's page shows |
|---|---|---|
| **Loading** | The request is on its way. | *Looking up types of hound...* |
| **Success** | The data came back. | The list: afghan, basset, blood... |
| **Empty** | The data came back, with nothing in it. | *The API lists no other types of beagle.* |
| **Error** | The request failed. | *We couldn't reach the dog database. Check your internet connection.* and a **Try again** button |

The shelter's next feature uses one more Dog CEO endpoint, `https://dog.ceo/api/breed/<breed>/list`, which gives back the types (sub-breeds) of a breed as an array. A hound has seven types; a beagle has none, so its array is empty. That one endpoint can show all four states.

## The page's parts

Each state needs a place on the page. This is the part of the shelter's page that matters:

```html
<label for="breed-name">Breed</label>
<input id="breed-name" value="hound">
<button id="show-types" type="button">Show types</button>
<p id="types-status" role="status"></p>
<ul id="types-list"></ul>
<button id="try-again" type="button" hidden>Try again</button>
```

- **`role="status"`** tells screen readers to read the paragraph aloud whenever its text changes, so someone who can't see the screen hears *Looking up types of hound...* too.
- **`hidden`** hides an element. In JavaScript, `tryAgain.hidden = true` hides the button, and `false` shows it.
- **`disabled`**, set from JavaScript with `button.disabled = true`, grays a button out so it can't be clicked.

## Loading: say so straight away

The loading message goes up **before** the request starts, in the same click. If it waits until after `await fetch(...)`, the page shows nothing for exactly the time it should be saying "wait":

```javascript
statusLine.textContent = `Looking up types of ${breed}...`;
list.replaceChildren();
showButton.disabled = true;

const response = await fetch(`https://dog.ceo/api/breed/${breed}/list`);
```

Clearing the old list with `replaceChildren()` (called with nothing in the parentheses, it removes every child) keeps the last breed's types from sitting under the new message. Disabling the button stops a quick double click from sending two requests, which could finish in either order. The button has to be enabled again however the request ends, which is the job of `finally` (below).

**Try it.** In DevTools' **Network** tab, change **No throttling** to **Slow 4G** and click the button: the loading message stays up long enough to read. Without throttling, it may flash by too fast to see, and that's fine.

## Empty: an answer with nothing in it

An empty answer isn't an error: the request worked, and there's nothing to show. A blank list looks broken, so say so, and where you can, say what the person might try. A small function that picks the words can be checked in Node:

```javascript
function typesText(breed, types) {
  if (types.length === 0) {
    return `The API lists no other types of ${breed}.`;
  }
  return `Types of ${breed}: ${types.length}`;
}

console.log(typesText('hound', ['afghan', 'basset', 'blood']));
console.log(typesText('beagle', []));
```

```text
Types of hound: 3
The API lists no other types of beagle.
```

The same goes for a search box: *No breeds match "zebra". Try fewer letters.* is an empty state, and it's far kinder than an empty box.

## Error: what happened, and what to do next

A good error message has two parts, in plain words: what went wrong, and what the person can do about it. *Something went wrong* has neither. *We couldn't find a breed called "unicorn". Check the spelling.* has both.

Show the friendly words on the page, and keep the real error for yourself in the Console:

```javascript
} catch (error) {
  console.error(error);
  statusLine.textContent = friendlyMessage(error, breed);
  tryAgain.hidden = false;
}
```

`friendlyMessage` is the kind of function you wrote in *When fetch Fails*: it reads `error.name` and picks the words. A failed request is often fixed by asking again a few seconds later (the Wi-Fi came back, the server restarted), so the error state shows the **Try again** button.

## The whole thing, with Try again

Here's the shelter's types panel from top to bottom: loading, then success, empty or error, and `finally` to put the button back:

```javascript
const input = document.querySelector('#breed-name');
const showButton = document.querySelector('#show-types');
const tryAgain = document.querySelector('#try-again');
const statusLine = document.querySelector('#types-status');
const list = document.querySelector('#types-list');

async function showTypes() {
  const breed = input.value.trim().toLowerCase();
  statusLine.textContent = `Looking up types of ${breed}...`;
  list.replaceChildren();
  showButton.disabled = true;
  tryAgain.hidden = true;

  try {
    const response = await fetch(`https://dog.ceo/api/breed/${breed}/list`, {
      signal: AbortSignal.timeout(8000),
    });
    if (!response.ok) {
      throw new Error(`Status ${response.status}`);
    }
    const data = await response.json();
    for (const type of data.message) {
      const item = document.createElement('li');
      item.textContent = type;
      list.appendChild(item);
    }
    statusLine.textContent = typesText(breed, data.message);
  } catch (error) {
    console.error(error);
    statusLine.textContent = friendlyMessage(error, breed);
    tryAgain.hidden = false;
  } finally {
    showButton.disabled = false;
  }
}

showButton.addEventListener('click', showTypes);
tryAgain.addEventListener('click', showTypes);
```

Both buttons run the same function, so **Try again** really does try the same thing again, and every run starts by hiding the **Try again** button. The list is built with `createElement` and `textContent`, never by gluing the API's text into HTML: text from someone else's server is shown as text, so it can never turn into code on your page.

`friendlyMessage(error, breed)` isn't shown here. Write your own, with a message for a `404` (*We couldn't find a breed called "unicorn". Check the spelling.*), a timeout and a network error.

**Try it.** Build the panel in your `dog-explorer` folder: the HTML above in `index.html`, and this code with `typesText` and your own `friendlyMessage` in `app.js`. Then visit every state on purpose: `hound` (success), `beagle` (empty), `unicorn` (error), and **Offline** in the Network tab (error, then set it back and click **Try again**).

![The shelter's types panel in its four states, side by side: "Looking up types of hound..." with no list yet; a list of seven hound types; "The API lists no other types of beagle."; and an error message with a Try again button under it](https://fsdtrainingmedia.blob.core.windows.net/public/images/2026/10/5790b2aae5084a3ea22f4c9928f80b25.webp)

## Check yourself

2 questions to try on the lesson page.

## So what

A page that says *loading*, *nothing here*, or *that didn't work, try again* feels trustworthy even when the network isn't, and those three messages are what people remember about an app on a bad day.

## Key terms

- [finally Block](https://www.ask-codey.com/glossary/finally-block-javascript)
- [hidden](https://www.ask-codey.com/glossary/hidden)
- [disabled](https://www.ask-codey.com/glossary/disabled)

## Cheat sheets

- [Loading, Empty and Error States](https://www.ask-codey.com/cheat-sheets/javascript-fetch-loading-and-error-states-cheat-sheet)

## Related

- [When fetch Fails](https://www.ask-codey.com/lessons/javascript-fetch-when-it-fails)
- [Responding to Clicks with addEventListener](https://www.ask-codey.com/lessons/javascript-events-add-event-listener)
- [Common DOM Interactions](https://www.ask-codey.com/lessons/working-with-elements-common-dom-interactions)

Source: https://www.ask-codey.com/lessons/javascript-fetch-loading-and-error-states
