Any component that loads data ends up with the same refs: one for the result, one for "loading", one for the error. Then the user types faster than the server answers, and an old response overwrites a newer one. This tutorial replaces all of that with four VueUse functions (useAsyncState, useFetch, createFetch and until) and builds a small book search on the Open Library API, which needs no key. By the end you will know when to reach for useAsyncState, when for useFetch, and the mistake that makes each of them look broken.
The examples use VueUse 14.2 and Vue 3.5 in a project created with npm create vue@latest, with explicit imports and the @ alias for src/ that create-vue sets up.
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():
<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
stateholds until the promise resolves, so the template never seesundefined. - A rejected promise lands in
errorinstead of crashing the component. By default the error is also passed toreportError, so it still shows in the console. stateis ashallowRef: replacing the whole value triggers an update, but mutating a nested property does not. Passshallow: falseif 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:
<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: falsekeeps the previous book on screen while the next one loads. The default,true, putsstateback tonullfirst, 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:
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:
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. Thetimeoutoption aborts after a number of milliseconds..json()parses the body as JSON (the default is text), and.json<Book[]>()typesdataasBook[] | 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:
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
},
},
})
baseUrlis put in front of any relative URL.fetchOptionsare passed tofetch.optionsare defaults foruseFetchitself. When a call passes its own hooks, they run after the client's hooks, because the defaultcombinationis'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:
<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:
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.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 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.