Skip to content
TutorialData & APIs

Fetch data in a component: loading, error and the empty state

A component that calls fetch() has to handle more than the happy path. Here is a book list that is honest about loading, failure and emptiness, written around one status ref that says which state the list is in.

vuejs.school team17 Jun 20256 min readVue 3.5

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.

components/BookList.vue
<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.

components/BookList.vueBreaks
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.

components/BookList.vueWorks
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.

components/BookList.vue
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>.

components/BookList.vue
<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.

components/BookList.vue
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:

components/BookList.vue
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.

components/BookList.vue
<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.

Check it stuck3 questions from the Fetching data in a component quiz.
Practice now
Was this helpful?

Keep reading