Network requests take time and sometimes fail. A good interface shows what is happening at every moment. Think of four states:
| State | What the user needs to see |
|---|---|
| Loading | “Loading…”, a spinner, a disabled button |
| Success | the data |
| Empty | “No results yet”, with a helpful hint |
| Error | a clear message, and a way to try again |
One pattern for all four
<style>
.error { color: #b00020; }
</style>
<button id="load">Load articles</button>
<p id="status" role="status"></p>
<ul id="articles"></ul>
<script>
// Three versions of "the server": success, empty and failure
const sources = [
() => new Response(JSON.stringify([{ title: "Intro to fetch" }, { title: "Async patterns" }])),
() => new Response("[]"),
() => new Response("Server error", { status: 500 })
];
let attempt = 0;
const wait = ms => new Promise(r => setTimeout(r, ms));
const button = document.querySelector("#load");
const status = document.querySelector("#status");
const list = document.querySelector("#articles");
async function load() {
button.disabled = true;
status.className = "";
status.textContent = "Loading…";
list.replaceChildren();
try {
await wait(500); // pretend network delay
const response = sources[attempt++ % sources.length]();
if (!response.ok) throw new Error("Server error " + response.status);
const articles = await response.json();
if (articles.length === 0) {
status.textContent = "No articles yet. Check back soon.";
} else {
status.textContent = `${articles.length} articles`;
for (const a of articles) {
const li = document.createElement("li");
li.textContent = a.title;
list.append(li);
}
}
} catch (error) {
status.className = "error";
status.textContent = "Could not load articles. Please try again.";
} finally {
button.disabled = false; // always re-enable
}
}
button.addEventListener("click", load);
</script>
Press the button three times: you see success, then the empty state, then an error, and each click works as a “retry”.
Why each piece matters
- Disable the button while loading, to avoid duplicate requests.
finallymakes sure the interface comes back to life, even after an error.role="status"makes screen readers announce the message when it changes.- Clear old content before a new load, so stale data is not mistaken for new data.
- Plain-language errors, never raw messages such as
TypeError: Failed to fetch.
Avoid flicker
If a request usually takes 50 ms, a spinner that flashes for one frame looks worse than nothing. Show the loading state after a short delay (about 200 ms), or keep the old content visible while refreshing.
Keep the state in one place
In bigger apps, store the state as a value and render from it:
let state = { status: "idle", data: null, error: null }; // idle | loading | success | error
That prevents impossible combinations, like “loading” and “error” at the same time.