Learning path · Level 7 · Tutorial 5

Render Loading, Empty, Error, and Recovery States

Build a data-loading interface that communicates progress, displays results or an empty outcome, and offers recovery after failure. Keep its controls and accessible feedback consistent throughout each request.

  • Level: Intermediate
  • Duration: 22 min
  • 8 steps
Start tutorial Back to the path
Render Loading, Empty, Error, and Recovery States

What you will go through

  1. Define the Resource Lifecycle Model the Book List request lifecycle with coordinated state, data, and error information. 2 min
  2. Build a Stable, Testable Interface Set up the local Book List shell, deterministic JSON fixtures, and an idle-only rendering foundation for later loading states. 3 min
  3. Render Results and Valid Empty Outcomes Render populated and valid empty Book List responses from centralized state while keeping obsolete content out of the results region. 3 min
  4. Enter Loading Without Leaving Stale Feedback Start each replacement load with clean feedback, temporarily unavailable request controls, and a repeatable local loading preview. 3 min
  5. Announce Progress Without Moving Focus Use separate busy and status semantics to announce Book List progress and completion while preserving keyboard focus. 3 min
  6. Render Failure and Restore Usable Controls Handle rejected reads as visible error outcomes, then use a final render to restore controls and busy semantics without losing that outcome. 4 min
  7. Provide an Explicit Retry Path Use the persistent action button to retry failed reads, reset a changed fixture to idle, and verify that recovery depends on the next request outcome. 2 min
  8. Verify the Complete Lifecycle Manually verify every Book List lifecycle state, replacement transition, and explicit recovery path in your local application. 3 min

What you will learn

  • Represent and render each meaningful stage of a data-loading operation.
  • Show different feedback for an empty valid response and a failed request.
  • Expose loading and completion feedback without moving focus unnecessarily.
  • Restore usable controls after failure and let the user retry.
  • Manually verify each state using local JSON files and delayed operations.

Before you start

  • Fetch and Validate JSON Responses
  • Keep Interface State and Rendering Consistent
  • Show Accessible Form Validation Feedback

Step 1 of 8

Define the Resource Lifecycle

Model the Book List request lifecycle with coordinated state, data, and error information.

See the lifecycle

From request to outcome

One request, one meaningful state

A Book List request is not simply “done” or “not done.” Model its lifecycle with five states:

  • idle — no request is in progress yet.
  • loading — a request is waiting for an outcome.
  • success — a validated collection contains one or more books.
  • empty — a validated collection contains no books.
  • error — the app could not obtain a usable collection.

A request that finishes has reached an outcome, but that outcome is not automatically usable results. A valid [] is empty; a failed or unusable response is error.

A lifecycle diagram showing idle leading to loading, then branching to populated results, an empty result, or an error outcome.

After loading, exactly one terminal outcome describes what the request produced.

Describe the current outcome

Coordinate status, data, and error information

Keep related facts in one state object. This prevents combinations such as an error message paired with old books presented as the current result.

javascript
const state = {
  status: "idle",
  books: [],
  error: null
};

State-to-data rules

Use status to interpret the other fields. The collection is shown only when it belongs to the current outcome.

Valid Book List state snapshots
StatusbookserrorVisible feedback
idle[]nullNo request outcome yet
loading[]nullLoading books…
successValidated, nonempty booksnullLoaded count and book titles
emptyValidated []nullNo books found.
error[]User-facing messageBooks could not be loaded. Try again.

Keep outcomes internally consistent

These are invariants: rules that must stay true in every rendering. In particular, success requires a validated nonempty collection, empty requires a validated empty collection, and error requires a message with no displayed collection. Independent flags such as isLoading and hasError can accidentally allow contradictory combinations; this coordinated object gives each situation one meaning.

Practice: classify request situations

Classify the lifecycle

Match each Book List situation to its correct state or consistency judgment.

Tap an item and then its matching pair.

Step 2 of 8

Build a Stable, Testable Interface

Set up the local Book List shell, deterministic JSON fixtures, and an idle-only rendering foundation for later loading states.

Map the interface structure

Start with a stable shell

Build the Book List around elements that persist across every request: a fixture selector, one action button, a results region, and a separate status region.

Keep the action button outside the results region. Later, the list or visible outcome message can change without replacing the control the user activated.

Inside the results region, keep the book list and visible outcome message separate. The status region is a separate persistent element and starts empty while the interface is idle.

Separate persistent and replaceable areas

Diagram showing a selector and persistent action button outside a results region, with a list and outcome area inside results and a separate status area below.

Keep the action control, replaceable results content, and status feedback as separate structural areas.

Idle means ready, not loading

Begin with the lifecycle snapshot already defined:

{ status: "idle", books: [], error: null }

Idle means the interface is ready, not loading. Its first render must not request a fixture automatically. The learner selects a fixture and explicitly starts a read in a later step.

Create reproducible local fixtures

package.json

If you need a minimal Vite project, create package.json, run npm install, then start the local server with npm run dev. Use the localhost address printed by Vite.

json
{
  "name": "book-list-states",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite"
  },
  "devDependencies": {
    "vite": "^6.0.0"
  }
}
Expected output
$ npm run dev

> book-list-states@1.0.0 dev
> vite

  VITE v6.x.x  ready in 150 ms

  ➜  Local:   http://localhost:5173/

The localhost URL and installed Vite version can differ on your computer.

public/data/books.json

Create these three files in public/data/. They provide reproducible populated, empty, and malformed outcomes without an external API.

json
[
  {
    "id": 1,
    "title": "Practical HTML"
  },
  {
    "id": 2,
    "title": "Everyday CSS"
  }
]

public/data/books-empty.json

json
[]

public/data/books-broken.json

Keep the trailing comma. This fixture is intentionally malformed, so JSON decoding will fail when it is requested later.

json
[{"id":1,"title":"Practical HTML"},]

Create the document and styles

index.html

Create index.html. The stylesheet link makes the local styles apply. The results region has a name, while load-status is a persistent sibling outside it. Do not place the button inside results.

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Book List</title>
    <link rel="stylesheet" href="/src/style.css" />
  </head>
  <body>
    <main>
      <h1>Book List</h1>

      <label for="fixture-select">Choose a fixture</label>
      <select id="fixture-select">
        <option value="/data/books.json">Books</option>
        <option value="/data/books-empty.json">Empty books</option>
        <option value="/data/books-broken.json">Broken books</option>
      </select>
      <button id="load-button" type="button" aria-disabled="false">Load books</button>

      <section id="results" aria-labelledby="results-heading" aria-busy="false">
        <h2 id="results-heading" class="visually-hidden">Book results</h2>
        <ul id="book-list"></ul>
        <p id="outcome-message"></p>
      </section>

      <p
        id="load-status"
        role="status"
        aria-atomic="true"
        class="visually-hidden"
      ></p>
    </main>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

src/style.css

Create src/style.css. The .visually-hidden class removes the status element from the visual layout while preserving it in the document.

css
body {
  font-family: system-ui, sans-serif;
  line-height: 1.5;
  margin: 0;
}

main {
  margin: 0 auto;
  max-width: 42rem;
  padding: 2rem;
}

#results {
  border: 1px solid #b8c1cc;
  margin-top: 1rem;
  min-height: 5rem;
  padding: 1rem;
}

.visually-hidden {
  clip: rect(0, 0, 0, 0);
  height: 1px;
  margin: -1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
  white-space: nowrap;
  width: 1px;
}

Add the idle starter

src/main.js

Create src/main.js. Use let because later steps replace the entire state object for each lifecycle transition. This starter renders only the initial idle view: it does not attach a request handler or request a fixture. The supplied helper reuses established HTTP, JSON, and record-validation checks, with cache-free fixture reads.

javascript
let state = {
  status: "idle",
  books: [],
  error: null
};

const fixtureSelect = document.querySelector("#fixture-select");
const loadButton = document.querySelector("#load-button");
const resultsRegion = document.querySelector("#results");
const bookList = document.querySelector("#book-list");
const outcomeMessage = document.querySelector("#outcome-message");
const loadStatus = document.querySelector("#load-status");

function render() {
  resultsRegion.setAttribute("aria-busy", "false");
  bookList.replaceChildren();
  outcomeMessage.textContent = "";
  loadStatus.textContent = "";
  loadButton.textContent = "Load books";
  loadButton.setAttribute("aria-disabled", "false");
  fixtureSelect.disabled = false;
}

async function fetchBooks(url) {
  const response = await fetch(url, { cache: "no-store" });

  if (!response.ok) {
    throw new Error(`Request failed with status ${response.status}.`);
  }

  const data = await response.json();
  const hasValidBooks = Array.isArray(data) && data.every((book) => {
    return book !== null &&
      typeof book === "object" &&
      typeof book.id === "number" &&
      typeof book.title === "string";
  });

  if (!hasValidBooks) {
    throw new Error("The response does not contain valid book records.");
  }

  return data;
}

render();
Expected output
Browser page, initial idle state:

Book List
Choose a fixture  [Books ▼]
[Load books]

[empty bordered results region]

Results region: aria-busy="false"
Fixture selector: enabled
Action button: aria-disabled="false"
Visible outcome message: empty
Status region text: empty
JSON fixture requests: none

This is the expected browser UI after Vite loads the page. No Network request for a JSON fixture occurs yet.

Practice: separate persistent and replaceable content

Complete the stable shell

Complete the structural scaffold. Preserve the shown placement: the action button must stay outside the replaceable results region.

html
<label for="fixture-select">Choose a fixture</label>
<select id="fixture-select"></select>
<button id="load-button" type="button" aria-disabled="false">Load books</button>

<section id="" aria-labelledby="results-heading" aria-busy="false">
  <h2 id="results-heading" class="visually-hidden">Book results</h2>
  <ul id=""></ul>
  <p id=""></p>
</section>

<p id="" class="visually-hidden" role="status" aria-atomic="true"></p>

Step 3 of 8

Render Results and Valid Empty Outcomes

Render populated and valid empty Book List responses from centralized state while keeping obsolete content out of the results region.

Compare valid outcomes

Two valid completed outcomes

A completed Book List request has two valid data outcomes. After a response has been decoded and validated as a collection:

  • A collection with one or more book records is success.
  • A collection with zero records is empty.

Both outcomes mean the request produced usable data. They differ only in what that validated collection contains, so the results region must show different content.

Populated versus empty results

Side-by-side Book List results: two book titles in the populated result and a no-results message in the valid empty result.

Both are valid completed responses. The validated collection length determines the visible outcome.

State-to-display rule

The renderer must make the DOM agree with the current state snapshot.

A valid empty collection is not a request failure.
StateValidated booksVisible in the results region
success2 recordsPractical HTML and Everyday CSS
empty[]No books found.
idle[]No result content yet

Render one internally consistent outcome

Edit the existing renderer

Step 2 already created render(). Do not add a second function declaration. Replace that existing render() function with the version below, then keep the existing render(); call at the bottom of src/main.js.

This single renderer first clears result-specific DOM content, applies the stable idle control settings, and then adds only the content for success or empty. Later steps will extend this same function for loading, status feedback, and errors.

One render() for idle and valid outcomes

Replace the render() function from Step 2 with this integrated version. It handles idle, success, and empty; later states will be added to this same function.

javascript
function render() {
  resultsRegion.setAttribute("aria-busy", "false");
  bookList.replaceChildren();
  outcomeMessage.textContent = "";
  loadStatus.textContent = "";
  loadButton.textContent = "Load books";
  loadButton.setAttribute("aria-disabled", "false");
  fixtureSelect.disabled = false;

  if (state.status === "success") {
    for (const book of state.books) {
      const item = document.createElement("li");
      item.textContent = book.title;
      bookList.append(item);
    }
    return;
  }

  if (state.status === "empty") {
    outcomeMessage.textContent = "No books found.";
  }
}
Expected output
Visible interface after the existing render() call with the initial idle state:
- Results region: no list items and no visible outcome message
- Fixture selector: enabled
- Action button: Load books; aria-disabled="false"
- Results region: aria-busy="false"
- Status region: empty

After saving with the initial idle state, the results region is blank. The selector is enabled, the button reads “Load books,” `aria-disabled` is “false,” and the results region has `aria-busy="false"`.

Tip

Clear incompatible content first

The clearing lines run before either valid-outcome branch. A change from a populated snapshot to an empty snapshot therefore removes every old <li> before the no-results message is written. The renderer never relies on a previous DOM outcome to decide the next one.

Inspect state snapshots without a request

Choose an outcome after validation

Use the established fetchBooks(url) helper as the boundary for valid data. Only after that helper resolves does the collection length choose success or empty.

Malformed JSON does not produce a validated collection. Its JSON decoding fails before a length can be checked, so it must not be represented as empty. A later step maps that failure to the error outcome.

Inspect a populated snapshot

To inspect rendering without request timing, temporarily replace the existing final render(); call with this success snapshot. Save, inspect the page, then restore the original render(); call before continuing.

javascript
state = {
  status: "success",
  books: [
    { id: 1, title: "Practical HTML" },
    { id: 2, title: "Everyday CSS" }
  ],
  error: null
};
render();
Expected output
Visible interface:
- Results region list: Practical HTML; Everyday CSS
- Visible outcome message: empty
- Fixture selector: enabled
- Action button: Load books; aria-disabled="false"
- Results region: aria-busy="false"
- Status region: empty

This is a temporary local rendering check. It does not request a fixture.

Inspect a valid empty snapshot

Next, temporarily replace the final render(); call with this empty snapshot. The list must be cleared rather than retained from the earlier snapshot. Restore the original render(); call when finished.

javascript
state = {
  status: "empty",
  books: [],
  error: null
};
render();
Expected output
Visible interface:
- Results region list: no items
- Visible outcome message: No books found.
- Fixture selector: enabled
- Action button: Load books; aria-disabled="false"
- Results region: aria-busy="false"
- Status region: empty

This is a temporary local rendering check. It does not request a fixture.

Practice: distinguish valid results

Complete the valid-outcome branch

Complete the valid-response branch. This code runs only after the helper has decoded and validated the collection.

javascript
const books = await fetchBooks(url);

state = books.length  0
  ? { status: , books, error: null }
  : { status: , books: [], error: null };

Step 4 of 8

Enter Loading Without Leaving Stale Feedback

Start each replacement load with clean feedback, temporarily unavailable request controls, and a repeatable local loading preview.

Clear the previous outcome

Replace the previous outcome

A replacement load starts a new request. While its outcome is unknown, previous book titles must not look like the new request’s result, and obsolete error information must not remain attached to it.

Before any await, replace the current state with a clean loading snapshot:

  • status is "loading".
  • books is an empty array.
  • error is null.

Call render() immediately after that assignment. The results region can then show Loading books… without old titles or a previous message. The controlled 1.2 s delay in this step makes that pending state observable.

A clean loading transition

Diagram showing previous book results and an earlier error outcome both cleared before a loading results panel is rendered, while the fixture selector is unavailable and the action button remains focusable.

Loading replaces the earlier outcome; it does not present old feedback as the result of the new request.

Render loading and control availability

Render a loading baseline

Replace the existing render() function from Step 3 with this version. It still handles the existing idle, success, and empty snapshots, but it now derives temporary control availability from state.status.

The fixture selector uses its native disabled property. The action button stays focusable and exposes its unavailable state through aria-disabled. Because aria-disabled alone does not block click events, loadBooks() will guard activation separately.

Extend render() for loading

Replace the existing render() function. Keep the existing final render(); call at the bottom of src/main.js.

javascript
function render() {
  const isLoading = state.status === "loading";

  resultsRegion.setAttribute("aria-busy", "false");
  bookList.replaceChildren();
  outcomeMessage.textContent = "";
  loadStatus.textContent = "";
  loadButton.textContent = "Load books";
  loadButton.setAttribute("aria-disabled", String(isLoading));
  fixtureSelect.disabled = isLoading;

  if (isLoading) {
    outcomeMessage.textContent = "Loading books…";
    return;
  }

  if (state.status === "success") {
    for (const book of state.books) {
      const item = document.createElement("li");
      item.textContent = book.title;
      bookList.append(item);
    }
    return;
  }

  if (state.status === "empty") {
    outcomeMessage.textContent = "No books found.";
  }
}
Expected output
During a loading render:
- Results region: no book list items; visible message "Loading books…"
- Fixture selector: disabled
- Action button: label "Load books" and aria-disabled="true"
- The button remains in the keyboard tab order because its native disabled property is not set.

When the temporary controller in the next screen assigns the loading snapshot and calls render(), inspect the interface during its 1.2 s pending period.

Tip

Availability needs two parts

Do not set loadButton.disabled. A native disabled button is removed from normal keyboard navigation. Here, aria-disabled="true" communicates temporary unavailability while the JavaScript guard prevents a second request from starting.

Observe one controlled pending operation

Use an observable, repeatable preview

Add this temporary, repeatable loading preview before the existing final render(); call in src/main.js. It intentionally does not call fetchBooks() yet: after the 1.2 s observation period, it returns to idle so the application remains usable for another attempt.

The local url is captured before rendering disables the selector. In Step 6, replace this temporary controller with the controller that reads the fixture and selects a terminal outcome.

Preview one pending operation

Add this code before the existing final render(); call. Save the file, select a fixture, and activate Load books to inspect the transition.

javascript
function delay(ms) {
  return new Promise((resolve) => {
    window.setTimeout(resolve, ms);
  });
}

async function loadBooks() {
  if (state.status === "loading") {
    return;
  }

  const url = fixtureSelect.value;
  state = {
    status: "loading",
    books: [],
    error: null
  };
  render();

  await delay(1200);

  state = {
    status: "idle",
    books: [],
    error: null
  };
  render();
  console.info(`Loading preview finished for ${url}.`);
}

loadButton.addEventListener("click", () => {
  loadBooks();
});
Expected output
Immediately after activation:
- The previous list or outcome message is cleared.
- "Loading books…" is visible.
- The fixture selector is disabled.
- The action button has aria-disabled="true" but remains focusable.

After 1.2 s:
- The loading message is cleared.
- The fixture selector is enabled again.
- The action button has aria-disabled="false".
- Console example: Loading preview finished for /data/books.json.

The final console line contains the path selected when the action began. The visible interface returns to idle after the preview; this is intentional until the request controller is completed in Step 6.

Tip

Inspect the pending period

During the 1.2 s period, activate the focused button again with Enter or Space. The event still occurs, but the guard returns immediately because the current state already represents a pending operation. After the preview returns to idle, you can select a fixture and repeat the observation without reloading the page.

Order the entry actions

Sequence the loading entry

After the pending-request guard has allowed activation, put these replacement-load entry actions in their required order.

  1. Await delay(1200).
  2. Replace state with the pending snapshot: empty books and null error.
  3. Read fixtureSelect.value into a local url.
  4. Call render().

Prevent a second activation

Complete the pending-request protection

Complete the state checks and transition that keep a second activation from beginning another operation.

javascript
const isPending = state.status === "";
fixtureSelect.disabled = isPending;
loadButton.setAttribute("aria-disabled", String(isPending));

async function loadBooks() {
  if (state.status === "") {
    return;
  }

  const url = fixtureSelect.value;
  state = {
    status: "",
    books: [],
    error: null
  };
  render();

  await delay(1200);
}

Step 5 of 8

Announce Progress Without Moving Focus

Use separate busy and status semantics to announce Book List progress and completion while preserving keyboard focus.

Separate the regions

Separate busy content from announcements

Two signals, two jobs

A results update needs separate accessibility signals:

  • The results region uses aria-busy="true" while its contents are being replaced. It returns to "false" after the interface has rendered the settled outcome.
  • The persistent status region gives a short, polite progress or completion announcement.

Keep load-status outside results. If a status message were inside busy content, assistive technology could defer it along with the changing results. The book list itself is not live, so inserting titles does not announce each title individually.

Separate the changing content from the announcement

Diagram of a changing Book List results region, a separate status region outside it, and focus remaining on the persistent action button.

The results region is busy while it changes. The separate status region announces concise progress and completion feedback.

Tip

Keep the status region available

Keep the status region available to assistive technology. The existing .visually-hidden class removes it from visual layout while preserving it in the document and accessibility tree. Do not use hidden or display: none for load-status.

role="status" provides polite status behavior, and aria-atomic="true" asks for the complete new message rather than only a changed fragment.

Use one coherent renderer

Render busy, visible, and status feedback together

Replace the render() function from Step 4 with this version. It updates the busy state before changing result content, then leaves aria-busy true only for the loading state. There are no early returns, so control, visible-result, and status updates run for every state.

Keep the existing render(); call and the temporary loadBooks() preview from Step 4. Step 6 will connect this renderer to the completed request controller.

javascript
function render() {
  const isLoading = state.status === "loading";

  resultsRegion.setAttribute("aria-busy", "true");
  bookList.replaceChildren();
  outcomeMessage.textContent = "";
  loadStatus.textContent = "";
  loadButton.textContent = "Load books";
  loadButton.setAttribute("aria-disabled", String(isLoading));
  fixtureSelect.disabled = isLoading;

  if (isLoading) {
    outcomeMessage.textContent = "Loading books…";
    loadStatus.textContent = "Loading books…";
  } else if (state.status === "success") {
    for (const book of state.books) {
      const item = document.createElement("li");
      item.textContent = book.title;
      bookList.append(item);
    }

    const count = state.books.length;
    loadStatus.textContent =
      `Loaded ${count} ${count === 1 ? "book." : "books."}`;
  } else if (state.status === "empty") {
    outcomeMessage.textContent = "No books found.";
    loadStatus.textContent = "No books found.";
  }

  resultsRegion.setAttribute("aria-busy", String(isLoading));
}
Expected output
Loading state:
results aria-busy="true"
Visible results message: Loading books…
Status region: Loading books…
Fixture selector: disabled
Action button: aria-disabled="true"

After the populated Books fixture settles:
results aria-busy="false"
Visible list: Practical HTML; Everyday CSS
Status region: Loaded 2 books.
Fixture selector: enabled
Action button: aria-disabled="false"

After a valid empty fixture settles:
results aria-busy="false"
Visible results message: No books found.
Status region: No books found.
Fixture selector: enabled
Action button: aria-disabled="false"

After a normal lifecycle transition, inspect the rendered state in the browser. The status text is visually hidden but available to assistive technology.

Announce; do not redirect focus

The action button is not created, removed, or replaced by render(). A person who activates it with the keyboard remains on that same button while the status region reports progress or completion.

Do not call .focus() for an ordinary Book List update. An announcement communicates the result; moving focus would unnecessarily interrupt the user's current keyboard position. The list remains non-live, so the status message gives a concise count instead of reading every inserted title.

Check the state-specific update

Complete the busy and completion updates

Complete the JavaScript that exposes the current busy state and produces grammatically correct completion feedback.

javascript
const isLoading = state.status === "loading";

resultsRegion.setAttribute("aria-busy", );

if (state.status === "success") {
  const count = state.books.length;
  loadStatus.textContent =
    `Loaded ${count} ${count === 1 ?  : }`;
}

Apply the complete pattern

Apply the announcement pattern

After a valid Book List request begins and then settles, describe the accessible feedback implementation in three to five sentences. State where load-status belongs relative to results, how aria-busy changes, which region announces loading and completion, and whether focus should move from the action button.

Write at least 140 characters (0/140).

Step 6 of 8

Render Failure and Restore Usable Controls

Handle rejected reads as visible error outcomes, then use a final render to restore controls and busy semantics without losing that outcome.

Make failure explicit

Failure is not an empty result

A request that fails has finished, but it has not produced usable books. Treat it as the terminal error state—not as empty.

The existing fetchBooks(url) helper rejects when the read fails, the HTTP response is unsuccessful, JSON is malformed, or decoded data fails validation. One catch maps all of those technical causes to one concise interface outcome.

For an error state, keep books as an empty array and store a user-facing message in error. This prevents titles from a previous request from looking like the response to the failed request.

Three settled outcomes

A diagram showing loading leading to populated results, an empty outcome, or an error outcome with no books displayed.

A failed request is a distinct terminal outcome; it does not become a valid empty collection.

Tip

Separate diagnostics from feedback

The browser Console can retain the technical error for debugging, but the interface should show the stable message: “Books could not be loaded. Try again.”

Map failures consistently

Each helper failure belongs to the same user-facing state.

The controller does not reinterpret a failure as a valid empty response.
What happensHelper resultInterface state
Network read is rejectedThrows or rejectserror
HTTP response is unsuccessfulThrowserror
JSON is malformedThrows while decodingerror
Decoded records fail validationThrowserror
Validated array has no recordsReturns []empty

Let render restore terminal settings

Render the stored outcome

Replace the existing render() function from Step 5 with this version. It preserves the busy, control, success, and empty behavior already established, then adds a small error branch.

The shared isLoading value restores terminal settings automatically. When an error state is rendered, it is no longer loading: aria-busy becomes "false", the selector is enabled, and the persistent button is available. Rendering does not reset the stored error state.

Extend render() with the error outcome

Replace the existing render() function. Keep the existing final render(); call and the temporary controller until the next screen replaces it.

javascript
function render() {
  const isLoading = state.status === "loading";

  resultsRegion.setAttribute("aria-busy", "true");
  bookList.replaceChildren();
  outcomeMessage.textContent = "";
  loadStatus.textContent = "";
  loadButton.textContent = "Load books";
  loadButton.setAttribute("aria-disabled", String(isLoading));
  fixtureSelect.disabled = isLoading;

  if (isLoading) {
    outcomeMessage.textContent = "Loading books…";
    loadStatus.textContent = "Loading books…";
  } else if (state.status === "success") {
    for (const book of state.books) {
      const item = document.createElement("li");
      item.textContent = book.title;
      bookList.append(item);
    }

    const count = state.books.length;
    loadStatus.textContent =
      `Loaded ${count} ${count === 1 ? "book." : "books."}`;
  } else if (state.status === "empty") {
    outcomeMessage.textContent = "No books found.";
    loadStatus.textContent = "No books found.";
  } else if (state.status === "error") {
    outcomeMessage.textContent = state.error;
    loadStatus.textContent = state.error;
  }

  resultsRegion.setAttribute("aria-busy", String(isLoading));
}
Expected output
For an error state, the results region shows “Books could not be loaded. Try again.” and contains no book list items.

The status region has the same message. The results region has aria-busy="false". The fixture selector is enabled, and the action button has aria-disabled="false".

The status region is visually hidden but remains available to assistive technology.

Warning

State invariant

Do not assign status: "empty" merely because books is []. Only a successfully decoded and validated empty response is empty. After a failure, the empty array clears obsolete display data; status: "error" explains why it is empty.

Connect loading, outcomes, and cleanup

Settle every request path

loadBooks() owns the request transition. It captures the selected path, enters loading, and renders before awaiting. Then try selects a valid terminal data outcome, while catch selects the error outcome.

finally performs the final render on both paths. Because it renders the terminal state already chosen by try or catch, it restores controls and clears busy semantics without resetting the outcome to idle or success.

Complete the guarded controller

Replace the temporary loadBooks() preview from Step 4 with this controller. Keep delay(ms), the existing click listener, and the final render(); call in src/main.js.

javascript
async function loadBooks() {
  if (state.status === "loading") {
    return;
  }

  const url = fixtureSelect.value;
  state = {
    status: "loading",
    books: [],
    error: null
  };
  render();

  try {
    await delay(1200);
    const books = await fetchBooks(url);

    state = books.length > 0
      ? { status: "success", books, error: null }
      : { status: "empty", books: [], error: null };
  } catch (error) {
    console.error(error);
    state = {
      status: "error",
      books: [],
      error: "Books could not be loaded. Try again."
    };
  } finally {
    render();
  }
}
Expected output
With /data/books.json selected: for 1.2 s, the results region shows “Loading books…”, has aria-busy="true", the selector is disabled, and the button has aria-disabled="true". It then shows Practical HTML and Everyday CSS; the status region says “Loaded 2 books.”; aria-busy is "false"; both controls are usable.

With /data/books-empty.json selected: it settles with “No books found.” in both outcome and status feedback, with no list items.

With /data/books-broken.json selected: it settles with “Books could not be loaded. Try again.” in both outcome and status feedback, with no list items. aria-busy is "false" and both controls are usable.

The 1.2 s delay makes the loading state observable before either terminal outcome is rendered.

Tip

One guarded async path

The 1.2 s delay is intentional: it makes the loading state observable during local checks. A rejected read from fetchBooks(url) reaches catch; finally still runs after either a valid outcome or that failure.

Practice: preserve error and restore usability

Complete failure cleanup

Complete the working controller. Its valid read path must still reach success or empty; its failure path must remain visible while the terminal render restores usable controls and clears busy state.

javascript
async function loadBooks() {
  if (state.status === "loading") {
    return;
  }

  const url = fixtureSelect.value;
  state = {
    status: "loading",
    books: [],
    error: null
  };
  render();

  try {
    await delay(1200);
    const books = await fetchBooks(url);

    state = books.length > 0
      ? { status: "success", books, error: null }
      : { status: "empty", books: [], error: null };
  } catch (error) {
    console.error(error);
    state = {
      status: "",
      books: [],
      error: 
    };
  } finally {
    ();
  }
}

Step 7 of 8

Provide an Explicit Retry Path

Use the persistent action button to retry failed reads, reset a changed fixture to idle, and verify that recovery depends on the next request outcome.

Keep recovery in the existing interface

Reuse the persistent control

An error is a terminal outcome, not the end of the interface. Keep the same persistent action button in the document and label it Retry when state.status is "error".

The button still starts the existing loadBooks() controller. A retry begins a new read of the selected fixture: the controller clears the previous error, enters "loading", and can settle as "success", "empty", or "error" again.

Do not add automatic retries. Recovery remains an explicit user action, so the user decides whether to try again.

A retry follows the same lifecycle

Diagram of a persistent action button leading from an error state through loading to either recovered book results or another error.

Retry uses the same control and controller: error → loading → a new terminal outcome.

Connect retry and fixture changes

Render the error-specific label

Update the existing render() function so its persistent button communicates the current action. Replace its current loadButton.textContent = "Load books"; line with the following expression. Keep the existing loading availability logic unchanged.

Choose the label from state

Replace the existing button-label line inside render().

javascript
loadButton.textContent =
  state.status === "error" ? "Retry" : "Load books";
Expected output
Error state:
Button label: Retry
Visible outcome: Books could not be loaded. Try again.
Status region: Books could not be loaded. Try again.
results aria-busy: false
Button aria-disabled: false

Expected rendered interface after an error state is rendered.

Keep one activation path and reset changed fixtures

At the bottom of src/main.js, replace the earlier click-listener block with this complete block. It preserves exactly one click listener and adds the fixture-change behavior; do not append a second click listener.

javascript
loadButton.addEventListener("click", () => {
  loadBooks();
});

fixtureSelect.addEventListener("change", () => {
  state = {
    status: "idle",
    books: [],
    error: null
  };
  render();
});
Expected output
After an error, activating Retry:
The same loadBooks() controller starts a replacement load.

After changing the selected fixture:
State: idle
Button label: Load books
Visible outcome: empty
Status region: empty
results aria-busy: false
Fixture selector: enabled

Expected interface behavior after the application renders following either interaction.

Practice one controller for both actions

Tip

Test explicit recovery

The fixture-change handler does not fetch anything. A different selected resource is a new load, so it clears the previous outcome and returns the interface to idle. If the selection does not change, Retry uses the same selected path captured when the new request begins.

To test recoverable failure, select /data/books.json, open your browser DevTools Network conditions, and switch Offline on. Activate Load books and wait for the error. Restore connectivity without reloading the page, then activate Retry. The same selected path can now produce the populated outcome.

By contrast, /data/books-broken.json remains malformed. Retrying it without changing the selection should reach error again. Recovery is possible, not guaranteed.

Complete the shared activation path

Complete the shared persistent-control behavior.

Practice the full retry transition

Order the recovery transition

Put this retry lifecycle in the required order.

  1. The controller clears the old error, sets loading, and renders the replacement load.
  2. A failed Book List request is visible, and the persistent button is available as Retry.
  3. The user activates that button, and its one listener starts the shared controller.
  4. The new request settles as success, empty, or error again if the cause remains.

Step 8 of 8

Verify the Complete Lifecycle

Manually verify every Book List lifecycle state, replacement transition, and explicit recovery path in your local application.

1. Map the checks

Verify what the user experiences

Verify Book List in the browser, not only by reading its code. Start from a freshly loaded page: it should be idle, make no request automatically, show no books or outcome message, and leave both controls usable.

For each route, observe the visible results region, the separate status region, control availability, and aria-busy. The controlled await delay(1200) pause gives you 1.2 s to inspect loading before a fixture settles.

A normal fixture-selector change intentionally resets the interface to idle. To test a direct replacement transition from populated results to an empty outcome, use the Console method on the next screen instead. It changes the selector value without firing its change event, so the current success state remains until the existing button starts the replacement load.

Two replacement paths

Diagram comparing a normal selector change that resets Book List to idle with a Console-set selector value that preserves success until a replacement load starts.

Use the Console-set selector value only to inspect the direct success to loading to empty transition without editing a watched fixture.

Manual verification matrix

Work through these checks in your own browser. The observations describe behavior to confirm, not a substitute for making the check yourself.

Book List lifecycle and recovery checks
CheckActionObserve
Initial idleReload the page while onlineNo request, no results or outcome message, and usable controls
Populated successLoad /data/books.jsonTwo titles and status feedback for 2 books
Selector resetChange to /data/books-empty.jsonIdle view clears the previous outcome before a request
Direct replacementAfter success, set the selector value in Console without a change event; activate Load booksOld titles disappear during loading, then the empty outcome appears
Valid emptyLoad /data/books-empty.jsonNo-results feedback and no displayed titles
Malformed errorLoad /data/books-broken.jsonFailure feedback, no books, and a Retry label
Repeated failureActivate Retry with the broken fixture unchangedLoading, followed by the same failure outcome
Offline recoveryFail /data/books.json while offline, restore connectivity, then RetryLoading, followed by two titles and completion feedback

2. Run replacement and recovery checks

Run fixture, replacement, and recovery routes

Run the fixture routes in order:

  1. Load /data/books.json and wait for the two titles.
  2. Change the selector normally to /data/books-empty.json. Confirm that the change handler returns the interface to idle and clears the titles before you activate Load books. Then load it and confirm the empty outcome.
  3. Load /data/books.json again and wait for success. Keep this populated state for the direct replacement check.
  4. Use the Console command on this screen, then activate the existing Load books button. During its 1.2 s loading period, the old titles must be absent; it should settle as empty.
  5. Load /data/books-broken.json, then activate Retry once without changing the selection.
  6. For recovery, select /data/books.json normally, enable your browser DevTools Network Offline condition, and activate Load books. After the error, restore connectivity without reloading the page and activate Retry.

The helper uses fetch(url, { cache: "no-store" }), so the recovery attempt reads the same URL again rather than relying on a cached response.

Prepare a direct replacement without editing a fixture

After /data/books.json has settled with two titles, open the browser Console and run this exact command. Do not dispatch a change event. Then activate the existing Load books button with the mouse or keyboard.

javascript
document.querySelector("#fixture-select").value = "/data/books-empty.json";
Expected output
"/data/books-empty.json"

A browser Console commonly displays the assigned string with quotation marks. The command changes only the selector value; it does not run the selector change handler.

Check controls, busy state, status, and focus

The Console assignment changes what loadBooks() will capture when the button is activated, but it does not invoke the selector's change listener. The app therefore remains in its populated success state until activation enters loading. This makes the direct success → loading → empty check reproducible without changing public/data/books.json while Vite is watching it.

During every loading period, confirm that the fixture selector is natively disabled. The persistent action button remains in keyboard navigation with aria-disabled="true", while its JavaScript guard prevents another request. After success, empty, or error, both controls become usable and the results region ends with aria-busy="false".

Activate the button with the keyboard and keep focus on it. Feedback should update without a .focus() call or an automatic focus move. Inspecting text and attributes confirms what the app set, but it does not alone prove what a particular screen reader announced.

Tip

Keep the checks reproducible

Load the page before enabling Offline mode, and do not reload while Offline is enabled. Restore the normal connection before selecting Retry. For the direct replacement check, use the Console assignment rather than editing any file or dispatching a synthetic change event.

3. Apply the complete checklist

Record your lifecycle evidence

Write a concise report of what you actually observed in your local Book List application. Cover initial idle, delayed loading, populated success, valid empty, malformed-response error, repeated failure, and offline recovery.

Include the direct replacement route: after populated success, you set the selector value to /data/books-empty.json in the Console without a change event and then activated Load books. Report what happened to the old titles during loading and at settlement. Also report what happened to the old error message when the offline failure entered loading again for Retry.

Describe control availability during loading and after settlement, the final aria-busy value, status-region feedback, and where keyboard focus remained. Explain why a valid empty response and a malformed response require different outcomes. Mention any discrepancy you need to investigate.

Write at least 450 characters (0/450).

4. Review and continue

Summary

Lifecycle checklist

  • Begin in idle. A normal selector change intentionally returns the interface to idle and clears the previous outcome before a new load.
  • A replacement load must enter loading with obsolete titles or error feedback removed before its new outcome settles. A Console-set selector value can verify the direct populated-to-empty route without editing a watched fixture.
  • A validated nonempty collection is success, a validated empty collection is empty, and malformed JSON or another failed read is error.
  • During loading, the results region is busy and no second request can begin. Every settled path restores usable controls and clears busy state.
  • Keep concise updates in the separate status region and preserve keyboard focus. Retry is explicit and can recover only when the failing condition has changed.

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate