A component that loads data from an API needs a place for the result, a place for the error, a loading flag, and a way to load again when its input changes without letting an old response overwrite a new one. Written by hand in every component, that code comes out slightly different each time.
You can write it once instead, as a useFetch(url) composable in plain Vue with no library: it refetches when the URL changes and cancels the request it no longer needs. VueUse and Nuxt each ship a fuller useFetch for real projects. Building the small one yourself shows you what theirs do for you.
Start with three refs and one request
A composable is a function that uses the Composition API and returns reactive state. The first version takes a string and fetches it once:
import { ref, shallowRef } from 'vue'
export function useFetch(url: string) {
const data = shallowRef<unknown>(null)
const error = shallowRef<Error | null>(null)
const loading = ref(false)
async function load() {
loading.value = true
error.value = null
try {
const response = await fetch(url)
if (!response.ok) throw new Error(`Request failed with status ${response.status}`)
data.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
} finally {
loading.value = false
}
}
load()
return { data, error, loading }
}
Two choices here are deliberate:
dataanderrorareshallowRefs rather thanrefs. AshallowRefreacts only when you assign a new value to.value, and it does not wrap every nested property of the payload in a proxy. The composable always replaces the whole response and never mutates inside it, so deep tracking would be wasted work on a large JSON body.- The
response.okcheck is there becausefetchrejects only when the request itself fails, on a network error for example. A 404 or a 500 resolves normally. Without the check, a JSON error body would land indataas if it were the result, and an HTML error page would fail inresponse.json()with a confusing parse error.
Refetch when the URL changes
The composable should load again when the URL changes, which means the URL has to be something reactive. Vue has a type for "a plain value, a ref, or a getter (a function that returns the value)": MaybeRefOrGetter. And it has one function that reads all three: toValue(), added in 3.3, which returns a plain value as is, a ref's .value, or the result of calling a getter.
import { ref, shallowRef, toValue, watchEffect, type MaybeRefOrGetter } from 'vue'
export function useFetch(url: MaybeRefOrGetter<string>) {
const data = shallowRef<unknown>(null)
const error = shallowRef<Error | null>(null)
const loading = ref(false)
async function load(target: string) {
// same body as before, with fetch(target)
}
watchEffect(() => {
load(toValue(url))
})
return { data, error, loading }
}
watchEffect runs its function immediately and tracks every reactive value read during that synchronous run. The important line is toValue(url) inside the effect. What it registers depends on what the caller passed:
- A ref: the ref is registered, so assigning a new URL to it fetches again.
- A getter that reads
props.id: that prop is registered. - A plain string: nothing is registered, and the effect runs exactly once.
The call to load is not awaited, on purpose. The effect only needs to start the request, and everything it should track has been read by the time load is called.
Abort the request nobody wants any more
Now the race. The URL changes from /users/1/posts to /users/2/posts while the first request is in flight. Both responses arrive, in whichever order the network decides, and the last one to arrive wins. If that is the first one, the component shows user 1's posts under user 2's name.
The browser's tool for this is AbortController. You pass its signal to fetch, and calling abort() makes that fetch reject. Vue's tool for knowing when to abort is onWatcherCleanup(), added in 3.5. You call it inside a watcher with a cleanup function, and Vue runs that function just before the watcher runs again and when the watcher stops.
import { onWatcherCleanup, ref, shallowRef, toValue, watchEffect, type MaybeRefOrGetter } from 'vue'
export function useFetch(url: MaybeRefOrGetter<string>) {
const data = shallowRef<unknown>(null)
const error = shallowRef<Error | null>(null)
const loading = ref(false)
let controller: AbortController | undefined
async function load(target: string) {
controller?.abort()
controller = new AbortController()
const { signal } = controller
loading.value = true
error.value = null
try {
const response = await fetch(target, { signal })
if (!response.ok) throw new Error(`Request failed with status ${response.status}`)
data.value = await response.json()
} catch (e) {
if (signal.aborted) return
error.value = e instanceof Error ? e : new Error(String(e))
} finally {
if (!signal.aborted) loading.value = false
}
}
watchEffect(() => {
load(toValue(url))
onWatcherCleanup(() => controller?.abort())
})
return { data, error, loading }
}
What the new lines do:
loadnow begins by aborting whatever request came before it, so an older request can no longer overwrite the result of a newer one.- The
signal.abortedchecks make an aborted request leave quietly. Its rejection is not an error the user should see, and it must not switchloadingoff while the newer request is still running. - The
onWatcherCleanupline covers the caseloadcannot: when the component unmounts, Vue stops the effect, the cleanup runs, and the in-flight request is cancelled instead of writing to refs nobody is watching.
onWatcherCleanup is called synchronously inside the effect, before any await. That is the one rule it has: after an await, Vue no longer knows which watcher the cleanup belongs to.
Add refresh and a type parameter
Two more things make this usable. A component needs to reload on demand, after a failed request or after the user changed something on the server, so the composable returns a refresh function. And data should not be unknown: the caller knows what the endpoint returns, so let it say so with a generic type parameter.
import { onWatcherCleanup, ref, shallowRef, toValue, watchEffect, type MaybeRefOrGetter } from 'vue'
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const data = shallowRef<T | null>(null)
const error = shallowRef<Error | null>(null)
const loading = ref(false)
let controller: AbortController | undefined
async function load(target: string) {
controller?.abort()
controller = new AbortController()
const { signal } = controller
loading.value = true
error.value = null
try {
const response = await fetch(target, { signal })
if (!response.ok) throw new Error(`Request failed with status ${response.status}`)
data.value = (await response.json()) as T
} catch (e) {
if (signal.aborted) return
error.value = e instanceof Error ? e : new Error(String(e))
} finally {
if (!signal.aborted) loading.value = false
}
}
function refresh() {
return load(toValue(url))
}
watchEffect(() => {
load(toValue(url))
onWatcherCleanup(() => controller?.abort())
})
return { data, error, loading, refresh }
}
useFetch<Post[]>(...) now returns data typed as ShallowRef<Post[] | null>, which is what a template needs: check for null, then use it as a list. The cast on response.json() is honest about what is happening. fetch cannot know the shape of the body, so the type is a promise you make to the compiler, and nothing checks it at runtime. If you need that guarantee, validate the body with a schema library before you assign it.
refresh reuses load and reads the URL through toValue at call time, so it always reloads the URL the component is showing. Because load aborts the previous controller, pressing a refresh button twice quickly leaves only the second request running.
Use it in a component
This component lists a user's posts from JSONPlaceholder, a free fake API. @/ is the alias for src/ that create-vue sets up.
<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'
interface Post {
id: number
title: string
}
const { userId } = defineProps<{ userId: number }>()
const { data: posts, error, loading, refresh } = useFetch<Post[]>(
() => `https://jsonplaceholder.typicode.com/users/${userId}/posts`,
)
</script>
<template>
<p v-if="loading">Loading posts...</p>
<p v-else-if="error">
{{ error.message }}
<button type="button" @click="refresh">Try again</button>
</p>
<ul v-else-if="posts">
<li v-for="post in posts" :key="post.id">{{ post.title }}</li>
</ul>
</template>
The URL is passed as a getter. userId is a destructured prop, which the Vue 3.5 compiler turns into props.userId, so reading it inside the getter is tracked, and the component fetches again whenever the parent changes the prop. Nothing in the component mentions aborting, race conditions or state resets; the composable owns all of it.
Destructuring the result into posts, error, loading and refresh works because the composable returns an object of separate refs rather than one reactive object. That is the convention the Vue docs recommend for composables.
To see the abort, open the Network tab in Chrome's DevTools, turn on throttling, and have the parent change userId twice quickly. The request you switched away from shows (canceled).
What VueUse and Nuxt add
This useFetch is under forty lines. The library versions start from the same shape and add what you would otherwise write next:
- VueUse's
useFetchhasimmediate: falsefor loading on demand, request and response interceptors, a timeout, typed helpers such as.json(), and anexecutefunction that plays the role ofrefresh. One difference from ours: it refetches when the URL changes only if you passrefetch: true. - Nuxt's
useFetchworks differently: it runs during server-side rendering, sends the result to the browser in the page payload so the client does not fetch again, and deduplicates requests by key across components.
Both are built around toValue, watchers and AbortController, which is why building the small one by hand is worth an afternoon. The next time one of them behaves in a way you did not expect, you will know which of these lines it is coming from.