A component that shows a list from an API looks simple until you watch it on a slow connection. The list is empty for a second, then it appears; or the server is down and nothing appears at all; or the user really has no items and the page looks broken.
By the end of this post you will have a BookList.vue that tells the truth in every case: a loading message, an error with a retry button, a clear empty state and the list itself. It cancels the request if the user navigates away, and it keeps the old list on screen while a refresh is running. Plain Vue, the browser's fetch(), no library.
Start in onMounted
The first question is where the request goes. <script setup> runs once, synchronously, before the component renders. You cannot await at the top level without making the component's setup async, and a component with an async setup only renders inside a <Suspense> boundary. So the request starts in onMounted, which Vue calls after the first render, and the template renders a loading state in the meantime.
<script setup lang="ts">
import { onMounted, ref } from 'vue'
interface Book {
id: number
title: string
author: string
}
const books = ref<Book[]>([])
onMounted(async () => {
const response = await fetch('/api/books')
books.value = await response.json()
})
</script>
This is the version most people write first. It works when the server answers quickly with a non-empty list and never fails. That is three assumptions, and this post removes them one at a time.
Track four states with one status ref
The tempting next step is a loading flag and an error ref next to books. Three independent values can describe situations that make no sense, such as loading true and error set at the same time. Worse, they cannot tell "not loaded yet" from "loaded, no books": both are false, null and [], so the template cannot know when to show the empty message.
const loading = ref(false)
const error = ref<string | null>(null)
const books = ref<Book[]>([])
// eight combinations for four real states
Replace the loading flag with one status ref whose type lists exactly the states that exist. A request is either not started, running, failed or finished. The empty state is not a fifth status; it is the success state with zero items, so a computed property derives it.
import { computed, ref } from 'vue'
type Status = 'idle' | 'loading' | 'error' | 'success'
const status = ref<Status>('idle')
const error = ref<string | null>(null)
const books = ref<Book[]>([])
const isEmpty = computed(() => status.value === 'success' && books.value.length === 0)
error stays, but only as the message to show. status decides which branch of the template shows.
Write a load function that reports errors
Move the request into a load() function so that a retry button can call it again later.
fetch() rejects only when the request itself fails, on a network error for example. A 404 or a 500 is still a response, so the promise resolves with response.ok set to false. Check it yourself and throw. Without the check, an HTML error page makes response.json() fail with a parse error that never mentions the status, and a JSON error body lands in books as if it were the list.
async function load() {
status.value = 'loading'
error.value = null
try {
const response = await fetch('/api/books')
if (!response.ok) {
throw new Error(`Server answered ${response.status}`)
}
books.value = await response.json()
status.value = 'success'
} catch (e) {
error.value = e instanceof Error ? e.message : 'Something went wrong'
status.value = 'error'
}
}
onMounted(load)
The catch branch narrows e to Error before reading .message. With strict on, as in a new Vue project, TypeScript types a caught value as unknown, because anything can be thrown, including a string. The user sees Server answered 503 rather than a blank list, which is what you want them to tell you when they report it.
Branch the template on the status
With one status, the template is a single v-if chain. Each branch tests a different state, so only one can match. The branch to remember is the empty one: without it, a successful request with no books falls through to v-else and renders an empty <ul>.
<template>
<p v-if="status === 'loading'" role="status">Loading books…</p>
<div v-else-if="status === 'error'" role="alert">
<p>Could not load the books: {{ error }}</p>
<button type="button" @click="load">Try again</button>
</div>
<p v-else-if="isEmpty">No books yet. Add one to get started.</p>
<ul v-else>
<li v-for="book in books" :key="book.id">
{{ book.title }}, {{ book.author }}
</li>
</ul>
</template>
role="status" on the loading message and role="alert" on the error make them live regions, which screen readers read out without the user moving focus. Announcements are most reliable when the region is on the page before its text changes. Screen readers differ on a region that v-if inserts with its text already inside, so test these states with a screen reader.
The empty message says what to do next; "No results" alone makes people wonder whether the request failed.
The 'idle' state never shows, because onMounted moves straight to 'loading'. It is still worth having: it is what a test sees before mounting finishes, and it is where a component that only loads on a click would start.
Cancel on unmount
If the user opens the page and navigates away before the server answers, the promise still resolves later and writes into refs of a component that no longer exists. Vue tolerates that, but it is wasted work, and if the handler did anything with the DOM it would be a bug. The browser's AbortController fixes it: give fetch() a signal, and call abort() in onUnmounted.
import { onMounted, onUnmounted, ref } from 'vue'
let controller: AbortController | undefined
async function load() {
controller?.abort()
controller = new AbortController()
status.value = 'loading'
try {
const response = await fetch('/api/books', { signal: controller.signal })
// … as before
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return
// … as before
}
}
onMounted(load)
onUnmounted(() => controller?.abort())
controller is a plain variable, not a ref, because the template never reads it. Each load() aborts the previous request before starting its own, so two quick calls, such as a double click on a Refresh button, cannot leave two responses racing to fill the list. An aborted fetch() rejects with a DOMException whose name is AbortError, and the early return keeps that from being shown as an error: the request was cancelled on purpose, nothing went wrong.
Keep the old list while refreshing
Once the list is on screen, a refresh should not throw it away. With the code so far, calling load() again sets status to 'loading', the v-if chain hides the list and shows the loading message, and the list reappears a moment later. On a fast connection that is a flash of empty space; on a slow one the user loses what they were reading.
The fix is to only show the loading state when there is nothing to show yet, and to mark a refresh separately:
const refreshing = ref(false)
async function load() {
const hasData = status.value === 'success'
if (hasData) refreshing.value = true
else status.value = 'loading'
try {
// … fetch and assign as before
status.value = 'success'
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return
if (!hasData) status.value = 'error'
error.value = e instanceof Error ? e.message : 'Something went wrong'
}
refreshing.value = false
}
Now a failed refresh keeps the old list and the old 'success' status; error holds the message, and the finished component shows it in a small line above the list instead of replacing everything. refreshing is a boolean on purpose: it is not a fifth status, because the component is still in 'success' the whole time.
refreshing is reset after the try, not in a finally. An aborted call returns early from catch and never reaches that line, while a finally would run anyway and switch refreshing off with the newer request still running.
The finished component
Here is the whole of components/BookList.vue with every step in place.
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
interface Book {
id: number
title: string
author: string
}
type Status = 'idle' | 'loading' | 'error' | 'success'
const status = ref<Status>('idle')
const error = ref<string | null>(null)
const books = ref<Book[]>([])
const refreshing = ref(false)
const isEmpty = computed(() => status.value === 'success' && books.value.length === 0)
let controller: AbortController | undefined
async function load() {
controller?.abort()
controller = new AbortController()
const hasData = status.value === 'success'
if (hasData) refreshing.value = true
else status.value = 'loading'
error.value = null
try {
const response = await fetch('/api/books', { signal: controller.signal })
if (!response.ok) {
throw new Error(`Server answered ${response.status}`)
}
books.value = await response.json()
status.value = 'success'
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return
if (!hasData) status.value = 'error'
error.value = e instanceof Error ? e.message : 'Something went wrong'
}
refreshing.value = false
}
onMounted(load)
onUnmounted(() => controller?.abort())
</script>
<template>
<p v-if="status === 'loading'" role="status">Loading books…</p>
<div v-else-if="status === 'error'" role="alert">
<p>Could not load the books: {{ error }}</p>
<button type="button" @click="load">Try again</button>
</div>
<p v-else-if="isEmpty">No books yet. Add one to get started.</p>
<div v-else>
<p v-if="refreshing">Updating…</p>
<p v-else-if="error">Could not refresh: {{ error }}</p>
<button type="button" @click="load">Refresh</button>
<ul>
<li v-for="book in books" :key="book.id">
{{ book.title }}, {{ book.author }}
</li>
</ul>
</div>
</template>
The list branch is now a <div v-else> holding the "Updating…" line, the refresh error, a "Refresh" button and the <ul>. Keep that wrapper. A <p v-if="refreshing"> placed straight above <ul v-else> starts a new v-if chain, the v-else belongs to it, and the list vanishes during every refresh. Vue compiles that version without a warning.
When to stop writing this by hand
This component is about sixty lines, and every list in your app wants the same sixty. That is the point at which to move status, error, refreshing, load() and the abort logic into a composable, a function that returns those refs, and let each component keep only its template. VueUse ships one, and Nuxt has useFetch() built in. Write this version once first. Once you have seen all four states and the abort path in a component you own, every library version reads as a tidier spelling of code you already understand.