---
title: "Async state with useAsyncState and VueUse's useFetch · vuejs.school"
description: "Stop writing loading, error and result refs by hand. Build a book search with VueUse's useAsyncState, useFetch, createFetch and until, and avoid two traps."
canonical_url: "https://vuejs.school/community/blog/async-state-with-useasyncstate-and-vueuses-usefetch"
meta:
  "og:description": "Stop writing loading, error and result refs by hand. Build a book search with VueUse's useAsyncState, useFetch, createFetch and until, and avoid two traps."
  "og:image": "https://vuejs.school/_og/d/a_~QXN5bmMgc3RhdGUgd2l0aCB1c2VBc3luY1N0YXRlIGFuZCBWdWVVc2UncyB1c2VGZXRjaCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_Async%2Bstate%2Bwith%2BuseAsyncState%2Band%2BVueUse's%2BuseFetch%2Cdescription_~U3RvcCB3cml0aW5nIGxvYWRpbmcsIGVycm9yIGFuZCByZXN1bHQgcmVmcyBieSBoYW5kLiBCdWlsZCBhIGJvb2sgc2VhcmNoIHdpdGggVnVlVXNlJ3MgdXNlQXN5bmNTdGF0ZSwgdXNlRmV0Y2gsIGNyZWF0ZUZldGNoIGFuZCB1bnRpbCwgYW5kIGF2b2lkIHR3byB0cmFwcy4%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hc3luYy1zdGF0ZS13aXRoLXVzZWFzeW5jc3RhdGUtYW5kLXZ1ZXVzZXMtdXNlZmV0Y2gi%2Cs_mx2rxJq0_jOmDbhG.png"
  "og:image:alt": "Async state with useAsyncState and VueUse's useFetch · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Async state with useAsyncState and VueUse's useFetch · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~QXN5bmMgc3RhdGUgd2l0aCB1c2VBc3luY1N0YXRlIGFuZCBWdWVVc2UncyB1c2VGZXRjaCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_Async%2Bstate%2Bwith%2BuseAsyncState%2Band%2BVueUse's%2BuseFetch%2Cdescription_~U3RvcCB3cml0aW5nIGxvYWRpbmcsIGVycm9yIGFuZCByZXN1bHQgcmVmcyBieSBoYW5kLiBCdWlsZCBhIGJvb2sgc2VhcmNoIHdpdGggVnVlVXNlJ3MgdXNlQXN5bmNTdGF0ZSwgdXNlRmV0Y2gsIGNyZWF0ZUZldGNoIGFuZCB1bnRpbCwgYW5kIGF2b2lkIHR3byB0cmFwcy4%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hc3luYy1zdGF0ZS13aXRoLXVzZWFzeW5jc3RhdGUtYW5kLXZ1ZXVzZXMtdXNlZmV0Y2gi%2Cs_mx2rxJq0_jOmDbhG.png"
  "twitter:image:alt": "Async state with useAsyncState and VueUse's useFetch · vuejs.school"
---

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

## Wrap any promise with `useAsyncState`

`useAsyncState` takes a promise, or a function that returns one, plus an initial value. It returns `state`, `isLoading`, `error` and `isReady`, and it does not block your component while it waits. It works with any promise, not only HTTP. This component asks the browser how much space the site uses with `navigator.storage.estimate()`:

*src/components/StorageUsage.vue*

```
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'

const { state, isLoading, error } = useAsyncState(
  () => navigator.storage.estimate(),
  { usage: 0, quota: 0 },
)

const toMb = (bytes = 0) => Math.round(bytes / 1_000_000)
</script>

<template>
  <p v-if="isLoading">Checking storage…</p>
  <p v-else-if="error">Could not read storage usage.</p>
  <p v-else>This site uses {{ toMb(state.usage) }} MB of {{ toMb(state.quota) }} MB.</p>
</template>
```

- The second argument is what `state` holds until the promise resolves, so the template never sees `undefined`.
- A rejected promise lands in `error` instead of crashing the component. By default the error is also passed to `reportError`, so it still shows in the console.
- `state` is a `shallowRef`: replacing the whole value triggers an update, but mutating a nested property does not. Pass `shallow: false` if you need deep reactivity.

Two more options cover side effects: `onSuccess(data)` and `onError(error)` run after each call, which is the place for a toast or a log line. Your own `onError` replaces the default `reportError` call.

The return value can also be awaited, as in `const { state } = await useAsyncState(load, [])`. That makes the component async, so a parent has to wrap it in `<Suspense>`; leave the `await` out unless you want that.

## Load on demand with `executeImmediate`

Often you want to run the call later, with an argument: here, the key of the book the user picked. Set `immediate: false` and call it yourself:

*src/components/BookDetails.vue*

```
<script setup lang="ts">
import { watch } from 'vue'
import { useAsyncState } from '@vueuse/core'

type Work = { title: string; description?: string | { value: string } }

const props = defineProps<{ workKey: string }>()

async function loadWork(key: string): Promise<Work> {
  const res = await fetch(`https://openlibrary.org${key}.json`)
  if (!res.ok) throw new Error(`Open Library answered ${res.status}`)
  return res.json()
}

const { state: work, isLoading, error, executeImmediate } = useAsyncState(
  loadWork,
  null,
  { immediate: false, resetOnExecute: false },
)

watch(() => props.workKey, key => executeImmediate(key), { immediate: true })
</script>

<template>
  <section aria-live="polite">
    <p v-if="error">Could not load this book.</p>
    <template v-else-if="work">
      <h2>{{ work.title }}</h2>
      <p>{{ typeof work.description === 'string' ? work.description : work.description?.value }}</p>
    </template>
    <p v-if="isLoading">Loading…</p>
  </section>
</template>
```

The `watch` loads the book at once (`immediate: true`) and again whenever `workKey` changes. Three more details:

- Open Library returns a work's description either as a string or as an object with a `value`, hence the type and the check in the template.
- `resetOnExecute: false` keeps the previous book on screen while the next one loads. The default, `true`, puts `state` back to `null` first, which makes the panel flash empty.
- If two calls overlap, only the latest one is allowed to write `state`, so a slow old response cannot overwrite a newer one.

Now the trap. The returned `execute` takes a delay in milliseconds as its first argument, and your arguments after it: `execute(500, key)`. Writing `execute(key)` hands your key over as the delay and calls `loadWork()` with nothing. TypeScript catches it with `Expected 2 arguments, but got 1`; plain JavaScript fetches `https://openlibrary.orgundefined.json`. Use `executeImmediate(key)`, which is `execute(0, key)` with a clearer name.

## Make VueUse's `useFetch` follow a reactive URL

When the input is a URL, `useFetch` is the better fit. It wraps the browser's `fetch`, tracks `data`, `error`, `isFetching` and `statusCode`, and can abort. The URL can be a ref or a computed, which looks like it should refetch whenever the URL changes. It does not:

*src/components/BookSearch.vue**Breaks***

```
import { computed, ref } from 'vue'
import { useFetch } from '@vueuse/core'

const term = ref('dune')
const url = computed(() => `https://openlibrary.org/search.json?q=${encodeURIComponent(term.value)}`)

const { data } = useFetch(url).json()
```

This fetches once. Changing `term` updates `url`, and nothing else happens, because `refetch` defaults to `false`. Turn it on:

*src/components/BookSearch.vue**Works***

```
const { data, isFetching, abort } = useFetch(url, { refetch: true }).json()
```

Now every change to `url` starts a new request, and starting a request aborts the one still in flight, so an old response can no longer overwrite a newer one.

- `abort()` lets you cancel the current request yourself, for a Stop button. The `timeout` option aborts after a number of milliseconds.
- `.json()` parses the body as JSON (the default is text), and `.json<Book[]>()` types `data` as `Book[] | null`.

`useFetch` has a manual mode too. With `immediate: false` it waits until you call `execute()`, which takes no delay argument, unlike its `useAsyncState` namesake. For side effects that should not live in the template, register a callback with `onFetchResponse` or `onFetchError`; both are returned alongside `data`.

## Configure one client with `createFetch`

Every call to the same API repeats the base URL and the headers. `createFetch` returns your own `useFetch` with those built in:

*src/composables/useOpenLibrary.ts*

```
import { createFetch } from '@vueuse/core'

export const useOpenLibrary = createFetch({
  baseUrl: 'https://openlibrary.org',
  fetchOptions: { headers: { Accept: 'application/json' } },
  options: {
    onFetchError(ctx) {
      // A request replaced by a newer one is not a failure.
      if (ctx.error?.name === 'AbortError') ctx.error = null
      return ctx
    },
  },
})
```

- `baseUrl` is put in front of any relative URL.
- `fetchOptions` are passed to `fetch`.
- `options` are defaults for `useFetch` itself. When a call passes its own hooks, they run after the client's hooks, because the default `combination` is `'chain'`.

The `onFetchError` option (not the event hook of the same name) runs before `error` is set and can change it. Here it works around a quirk in VueUse 14.2: when a newer request replaces an older one, the older request's abort error is written to `error`, even though `data` ends up holding the fresh result. A template that shows `error` would then report a failure that never happened. The hook turns those aborts back into `null`, and real failures, such as a 500 response, still come through.

Leave `timeout` out of a client with this hook. A request that runs out of time also fails with an `AbortError`, so the hook would hide it and leave the old results on screen with no error.

## Build the search page

`BookSearch.vue` puts the pieces together:

*src/components/BookSearch.vue*

```
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue'
import { refDebounced, until } from '@vueuse/core'
import { useOpenLibrary } from '@/composables/useOpenLibrary'
import BookDetails from './BookDetails.vue'

type SearchDoc = { key: string; title: string; author_name?: string[] }
type Book = { key: string; title: string; author: string }

const term = ref('')
const query = refDebounced(term, 300)
const selected = ref<string | null>(null)
const list = useTemplateRef<HTMLUListElement>('list')

const url = computed(() => `/search.json?q=${encodeURIComponent(query.value)}&limit=10&fields=key,title,author_name`)

const { data: books, isFetching, error, canAbort, abort } = useOpenLibrary(url, {
  refetch: true,
  beforeFetch({ cancel }) {
    if (query.value.trim().length < 2) cancel()
  },
  afterFetch(ctx) {
    ctx.data = ctx.data.docs.map((doc: SearchDoc): Book => ({
      key: doc.key,
      title: doc.title,
      author: doc.author_name?.[0] ?? 'Unknown author',
    }))
    return ctx
  },
}).json<Book[]>()

async function focusFirstResult() {
  await until(query).toBe(term)
  await until(isFetching).toBe(false, { timeout: 5000 })
  list.value?.querySelector('button')?.focus()
}
</script>

<template>
  <label for="book-q">Search books</label>
  <input id="book-q" v-model="term" type="search" @keydown.enter.prevent="focusFirstResult">
  <button v-if="canAbort" type="button" @click="abort()">Stop</button>
  <p v-if="error" role="alert">The search failed. Try again.</p>
  <ul v-if="query.trim().length >= 2" ref="list">
    <li v-for="book in books" :key="book.key">
      <button type="button" @click="selected = book.key">{{ book.title }} by {{ book.author }}</button>
    </li>
  </ul>
  <BookDetails v-if="selected" :work-key="selected" />
</template>
```

`useTemplateRef('list')` is the Vue 3.5 way to reach the element marked `ref="list"`; it is `null` while the list is hidden. `refDebounced` gives a copy of `term` that only updates after 300 milliseconds without typing, so the URL, and the request, change once per pause rather than once per key.

`beforeFetch` runs just before each request and receives the URL, the options and a `cancel` function. Here it skips queries shorter than two characters, which also stops the empty request on first render. It could equally add an auth header by returning changed `options`.

`afterFetch` runs after any successful response and before `data` is set. Open Library sends a large object with the books in `docs`; the hook keeps three fields per book, so the template and the type stay small.

`until()` turns a reactive condition into a promise. When the user presses Enter, `focusFirstResult` waits in two steps, then moves focus to the first result:

1. `until(query).toBe(term)` waits for the debounced copy to catch up with the input. Without it, an Enter during the 300 millisecond pause finds no request running yet and focuses the previous search's results.
2. `until(isFetching).toBe(false, { timeout: 5000 })` waits until no request is in flight, or five seconds pass.

That kind of "wait, then act" step is where `until` beats a `watch`, which you would have to set up and stop by hand. Without `throwOnTimeout: true` a timeout simply resolves the promise; with it, the promise rejects. Besides `toBe`, there are `toMatch(fn)`, `toBeTruthy()` and `changed()`.

The error paragraph shows a fixed sentence on purpose. After the `onFetchError` option has run, `error` holds an `Error` object, and interpolating it would print something like `Error: Internal Server Error` (or a bare `Error` over HTTP/2, which sends no status text). That is written for developers, not for the person using the page.

## When Nuxt's `useFetch` is the right tool

Everything above runs in the browser. In a Nuxt app, data that a page needs to render should come from Nuxt's own `useFetch` or `useAsyncData`. They run during server rendering, send the result to the browser inside the page payload, and do not fetch again during hydration. VueUse's `useFetch` knows nothing about that payload, so the browser would fetch the same data a second time. The [VueUse docs](https://vueuse.org/core/useFetch/) note that Nuxt auto-imports its own `useFetch`, and you only get VueUse's by importing it explicitly from `@vueuse/core`.

Use Nuxt's composables for page data in Nuxt. Use VueUse's `useFetch` and `useAsyncState` in a Vite single-page app, and for browser-only work in either.

**Check it stuck**3 questions from the Async state and network quiz.

[**Practice now**](https://vuejs.school/learn/vueuse/async-state-and-network?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Deep dive · 6 min read**VueUse 15's new composables: useWebMCP, useLiveAnnouncer and useTemporalNow**](https://vuejs.school/community/blog/vueuse-15s-new-composables-usewebmcp-useliveannouncer-usetemporalnow) [Release notes · 6 min read**VueUse 15: the breaking changes in plain words**](https://vuejs.school/community/blog/vueuse-15-the-breaking-changes-in-plain-words)

```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/async-state-with-useasyncstate-and-vueuses-usefetch#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-03-26","description":"Stop writing loading, error and result refs by hand. Build a book search with VueUse's useAsyncState, useFetch, createFetch and until, and avoid two traps.","name":"Async state with useAsyncState and VueUse's useFetch","url":"https://vuejs.school/community/blog/async-state-with-useasyncstate-and-vueuses-usefetch","breadcrumb":{"@id":"https://vuejs.school/community/blog/async-state-with-useasyncstate-and-vueuses-usefetch#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/async-state-with-useasyncstate-and-vueuses-usefetch"]}],"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/async-state-with-useasyncstate-and-vueuses-usefetch#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-03-26","description":"Stop writing loading, error and result refs by hand. Build a book search with VueUse's useAsyncState, useFetch, createFetch and until, and avoid two traps.","headline":"Async state with useAsyncState and VueUse's useFetch","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~QXN5bmMgc3RhdGUgd2l0aCB1c2VBc3luY1N0YXRlIGFuZCBWdWVVc2UncyB1c2VGZXRjaCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_Async%2Bstate%2Bwith%2BuseAsyncState%2Band%2BVueUse's%2BuseFetch%2Cdescription_~U3RvcCB3cml0aW5nIGxvYWRpbmcsIGVycm9yIGFuZCByZXN1bHQgcmVmcyBieSBoYW5kLiBCdWlsZCBhIGJvb2sgc2VhcmNoIHdpdGggVnVlVXNlJ3MgdXNlQXN5bmNTdGF0ZSwgdXNlRmV0Y2gsIGNyZWF0ZUZldGNoIGFuZCB1bnRpbCwgYW5kIGF2b2lkIHR3byB0cmFwcy4%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hc3luYy1zdGF0ZS13aXRoLXVzZWFzeW5jc3RhdGUtYW5kLXZ1ZXVzZXMtdXNlZmV0Y2gi%2Cs_mx2rxJq0_jOmDbhG.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/async-state-with-useasyncstate-and-vueuses-usefetch#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/async-state-with-useasyncstate-and-vueuses-usefetch#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/async-state-with-useasyncstate-and-vueuses-usefetch#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Async state with useAsyncState and VueUse's useFetch","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_~QXN5bmMgc3RhdGUgd2l0aCB1c2VBc3luY1N0YXRlIGFuZCBWdWVVc2UncyB1c2VGZXRjaCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_Async%2Bstate%2Bwith%2BuseAsyncState%2Band%2BVueUse's%2BuseFetch%2Cdescription_~U3RvcCB3cml0aW5nIGxvYWRpbmcsIGVycm9yIGFuZCByZXN1bHQgcmVmcyBieSBoYW5kLiBCdWlsZCBhIGJvb2sgc2VhcmNoIHdpdGggVnVlVXNlJ3MgdXNlQXN5bmNTdGF0ZSwgdXNlRmV0Y2gsIGNyZWF0ZUZldGNoIGFuZCB1bnRpbCwgYW5kIGF2b2lkIHR3byB0cmFwcy4%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hc3luYy1zdGF0ZS13aXRoLXVzZWFzeW5jc3RhdGUtYW5kLXZ1ZXVzZXMtdXNlZmV0Y2gi%2Cs_mx2rxJq0_jOmDbhG.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~QXN5bmMgc3RhdGUgd2l0aCB1c2VBc3luY1N0YXRlIGFuZCBWdWVVc2UncyB1c2VGZXRjaCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_Async%2Bstate%2Bwith%2BuseAsyncState%2Band%2BVueUse's%2BuseFetch%2Cdescription_~U3RvcCB3cml0aW5nIGxvYWRpbmcsIGVycm9yIGFuZCByZXN1bHQgcmVmcyBieSBoYW5kLiBCdWlsZCBhIGJvb2sgc2VhcmNoIHdpdGggVnVlVXNlJ3MgdXNlQXN5bmNTdGF0ZSwgdXNlRmV0Y2gsIGNyZWF0ZUZldGNoIGFuZCB1bnRpbCwgYW5kIGF2b2lkIHR3byB0cmFwcy4%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hc3luYy1zdGF0ZS13aXRoLXVzZWFzeW5jc3RhdGUtYW5kLXZ1ZXVzZXMtdXNlZmV0Y2gi%2Cs_mx2rxJq0_jOmDbhG.png"}]}
```