---
title: "Fetch data in a component: loading, error and the empty state · vuejs.school"
description: "Every list that comes from an API has four states, not one. Build a Vue component that shows all of them, cancels on unmount and retries cleanly."
canonical_url: "https://vuejs.school/community/blog/fetch-data-in-a-component-loading-error-empty"
meta:
  "og:description": "Every list that comes from an API has four states, not one. Build a Vue component that shows all of them, cancels on unmount and retries cleanly."
  "og:image": "https://vuejs.school/_og/d/a_~RmV0Y2ggZGF0YSBpbiBhIGNvbXBvbmVudDogbG9hZGluZywgZXJyb3IgYW5kIHRoZSBlbXB0eSBzdGF0ZSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~RmV0Y2ggZGF0YSBpbiBhIGNvbXBvbmVudDogbG9hZGluZywgZXJyb3IgYW5kIHRoZSBlbXB0eSBzdGF0ZQ%2Cdescription_~RXZlcnkgbGlzdCB0aGF0IGNvbWVzIGZyb20gYW4gQVBJIGhhcyBmb3VyIHN0YXRlcywgbm90IG9uZS4gQnVpbGQgYSBWdWUgY29tcG9uZW50IHRoYXQgc2hvd3MgYWxsIG9mIHRoZW0sIGNhbmNlbHMgb24gdW5tb3VudCBhbmQgcmV0cmllcyBjbGVhbmx5Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9mZXRjaC1kYXRhLWluLWEtY29tcG9uZW50LWxvYWRpbmctZXJyb3ItZW1wdHki%2Cs_qEQt_pDIoFc5HoS5.png"
  "og:image:alt": "Fetch data in a component: loading, error and the empty state · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Fetch data in a component: loading, error and the empty state · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~RmV0Y2ggZGF0YSBpbiBhIGNvbXBvbmVudDogbG9hZGluZywgZXJyb3IgYW5kIHRoZSBlbXB0eSBzdGF0ZSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~RmV0Y2ggZGF0YSBpbiBhIGNvbXBvbmVudDogbG9hZGluZywgZXJyb3IgYW5kIHRoZSBlbXB0eSBzdGF0ZQ%2Cdescription_~RXZlcnkgbGlzdCB0aGF0IGNvbWVzIGZyb20gYW4gQVBJIGhhcyBmb3VyIHN0YXRlcywgbm90IG9uZS4gQnVpbGQgYSBWdWUgY29tcG9uZW50IHRoYXQgc2hvd3MgYWxsIG9mIHRoZW0sIGNhbmNlbHMgb24gdW5tb3VudCBhbmQgcmV0cmllcyBjbGVhbmx5Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9mZXRjaC1kYXRhLWluLWEtY29tcG9uZW50LWxvYWRpbmctZXJyb3ItZW1wdHki%2Cs_qEQt_pDIoFc5HoS5.png"
  "twitter:image:alt": "Fetch data in a component: loading, error and the empty state · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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.vue**Breaks***

```
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.vue**Works***

```
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 stuck**3 questions from the Fetching data in a component quiz.

[**Practice now**](https://vuejs.school/learn/data/fetching-data-in-a-component?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Deep dive · 6 min read**Streaming AI responses: fetch, ReadableStream and the AI SDK**](https://vuejs.school/community/blog/streaming-ai-responses-fetch-readablestream-and-the-ai-sdk) [Tutorial · 8 min read**Authentication basics: tokens, cookies and where to keep them**](https://vuejs.school/community/blog/authentication-basics-tokens-cookies-and-where-to-keep-them)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/fetch-data-in-a-component-loading-error-empty#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-06-17","description":"Every list that comes from an API has four states, not one. Build a Vue component that shows all of them, cancels on unmount and retries cleanly.","name":"Fetch data in a component: loading, error and the empty state","url":"https://vuejs.school/community/blog/fetch-data-in-a-component-loading-error-empty","breadcrumb":{"@id":"https://vuejs.school/community/blog/fetch-data-in-a-component-loading-error-empty#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/fetch-data-in-a-component-loading-error-empty"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/fetch-data-in-a-component-loading-error-empty#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-06-17","description":"Every list that comes from an API has four states, not one. Build a Vue component that shows all of them, cancels on unmount and retries cleanly.","headline":"Fetch data in a component: loading, error and the empty state","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~RmV0Y2ggZGF0YSBpbiBhIGNvbXBvbmVudDogbG9hZGluZywgZXJyb3IgYW5kIHRoZSBlbXB0eSBzdGF0ZSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~RmV0Y2ggZGF0YSBpbiBhIGNvbXBvbmVudDogbG9hZGluZywgZXJyb3IgYW5kIHRoZSBlbXB0eSBzdGF0ZQ%2Cdescription_~RXZlcnkgbGlzdCB0aGF0IGNvbWVzIGZyb20gYW4gQVBJIGhhcyBmb3VyIHN0YXRlcywgbm90IG9uZS4gQnVpbGQgYSBWdWUgY29tcG9uZW50IHRoYXQgc2hvd3MgYWxsIG9mIHRoZW0sIGNhbmNlbHMgb24gdW5tb3VudCBhbmQgcmV0cmllcyBjbGVhbmx5Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9mZXRjaC1kYXRhLWluLWEtY29tcG9uZW50LWxvYWRpbmctZXJyb3ItZW1wdHki%2Cs_qEQt_pDIoFc5HoS5.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/fetch-data-in-a-component-loading-error-empty#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/fetch-data-in-a-component-loading-error-empty#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/fetch-data-in-a-component-loading-error-empty#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Fetch data in a component: loading, error and the empty state","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~RmV0Y2ggZGF0YSBpbiBhIGNvbXBvbmVudDogbG9hZGluZywgZXJyb3IgYW5kIHRoZSBlbXB0eSBzdGF0ZSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~RmV0Y2ggZGF0YSBpbiBhIGNvbXBvbmVudDogbG9hZGluZywgZXJyb3IgYW5kIHRoZSBlbXB0eSBzdGF0ZQ%2Cdescription_~RXZlcnkgbGlzdCB0aGF0IGNvbWVzIGZyb20gYW4gQVBJIGhhcyBmb3VyIHN0YXRlcywgbm90IG9uZS4gQnVpbGQgYSBWdWUgY29tcG9uZW50IHRoYXQgc2hvd3MgYWxsIG9mIHRoZW0sIGNhbmNlbHMgb24gdW5tb3VudCBhbmQgcmV0cmllcyBjbGVhbmx5Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9mZXRjaC1kYXRhLWluLWEtY29tcG9uZW50LWxvYWRpbmctZXJyb3ItZW1wdHki%2Cs_qEQt_pDIoFc5HoS5.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~RmV0Y2ggZGF0YSBpbiBhIGNvbXBvbmVudDogbG9hZGluZywgZXJyb3IgYW5kIHRoZSBlbXB0eSBzdGF0ZSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~RmV0Y2ggZGF0YSBpbiBhIGNvbXBvbmVudDogbG9hZGluZywgZXJyb3IgYW5kIHRoZSBlbXB0eSBzdGF0ZQ%2Cdescription_~RXZlcnkgbGlzdCB0aGF0IGNvbWVzIGZyb20gYW4gQVBJIGhhcyBmb3VyIHN0YXRlcywgbm90IG9uZS4gQnVpbGQgYSBWdWUgY29tcG9uZW50IHRoYXQgc2hvd3MgYWxsIG9mIHRoZW0sIGNhbmNlbHMgb24gdW5tb3VudCBhbmQgcmV0cmllcyBjbGVhbmx5Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9mZXRjaC1kYXRhLWluLWEtY29tcG9uZW50LWxvYWRpbmctZXJyb3ItZW1wdHki%2Cs_qEQt_pDIoFc5HoS5.png"}]}
```