A Fetch Composable
Extract the fetch pattern into a composable that takes a reactive URL, refetches when it changes, and returns a retry.
function useFetch<T>(url: MaybeRefOrGetter<string>): { data: Ref<T | null>; error: Ref<Error | null>; isLoading: Ref<boolean>; retry: () => void }A composable is a plain function that uses Vue's reactivity APIs and is called from a component's setup. By convention its name begins with use. The data, error, pending and abort bookkeeping is the same in every component, so write it once.
Taking a reactive URL
A composable that accepts a string can only ever fetch one address. Accept a MaybeRefOrGetter<string> — a string, a ref holding one, or a function returning one — and read it with toValue, added in Vue 3.3. Then watch it, so a changed URL refetches.
Passing `/api/users/${id.value}` defeats this: .value is read once at the call site and the composable receives a fixed string. Pass () => `/api/users/${id.value}` instead.
Cancelling the stale request
When the URL changes mid-request, the old response must not land. Create an AbortController in the watcher callback and register controller.abort() with onWatcherCleanup, added in Vue 3.5. It must be called during the synchronous part of the callback, before any await. The callback's third argument, onCleanup, does the same job and is not subject to that restriction — the two are interchangeable.
What to return
Return the refs, not data.value: a returned value is a dead snapshot. Return a retry too — a function that increments a counter the watcher also watches.
Example
// src/composables/useFetch.ts
import {
ref,
shallowRef,
toValue,
watch,
onWatcherCleanup,
type MaybeRefOrGetter,
type Ref,
} from 'vue'
export interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
isLoading: Ref<boolean>
retry: () => void
}
export function useFetch<T>(url: MaybeRefOrGetter<string>): UseFetchReturn<T> {
const data = shallowRef<T | null>(null)
const error = shallowRef<Error | null>(null)
const isLoading = ref(false)
const attempt = ref(0)
watch(
[() => toValue(url), attempt],
async ([target]) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
isLoading.value = true
error.value = null
try {
const res = await fetch(target, { signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = (await res.json()) as T
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
error.value = err instanceof Error ? err : new Error('Request failed')
} finally {
isLoading.value = false
}
},
{ immediate: true },
)
return { data, error, isLoading, retry: () => { attempt.value += 1 } }
}When to use it
- Stop repeating the same data, error, pending and abort block in a dozen components.
- Refetch a detail view automatically when a route parameter changes.
- Give a failed request a retry that re-runs exactly the same URL.
- Wrap one API resource in a named composable so the endpoint lives in one file.
More examples
A component using it
The URL is passed as a getter, so typing a new id refetches and the in-flight request for the old id is aborted. Destructuring the return is safe because every property is a ref — unlike destructuring a reactive object, which loses reactivity. Renaming data to user with data: user keeps the template readable when a component uses two of these. The screen shows a number input with the user's name under it; changing the number swaps the name, and a failed request shows a message with a Try again button.
<script setup lang="ts">
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'
interface User {
id: number
name: string
}
const userId = ref(1)
const { data: user, error, isLoading, retry } = useFetch<User>(
() => `/api/users/${userId.value}`,
)
</script>
<template>
<label>
User id
<input v-model.number="userId" type="number" min="1" />
</label>
<p v-if="isLoading">Loading user {{ userId }}</p>
<div v-else-if="error">
<p>Could not load the user: {{ error.message }}</p>
<button type="button" @click="retry">Try again</button>
</div>
<p v-else-if="user">{{ user.name }}</p>
<p v-else>No user with that id.</p>
</template>
<style scoped>
label {
display: block;
}
</style>A domain composable built on top
useFetch knows about HTTP; useUser knows about users. Taking MaybeRefOrGetter<number> here too means callers can pass a plain 7, a ref, or a getter reading a route param, and all three behave the same. Composables compose: the derived fullName is a computed over the fetched ref, so it updates when the request resolves without any extra wiring. This is the layer components should import, so a change to the endpoint touches one file.
// src/composables/useUser.ts
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
import { useFetch } from './useFetch'
export interface User {
id: number
firstName: string
lastName: string
}
export function useUser(id: MaybeRefOrGetter<number>) {
const { data, error, isLoading, retry } = useFetch<User>(
() => `/api/users/${toValue(id)}`,
)
const fullName = computed(() =>
data.value ? `${data.value.firstName} ${data.value.lastName}` : '',
)
return { user: data, fullName, error, isLoading, retry }
}
// With data.value = { id: 7, firstName: 'Ada', lastName: 'Lovelace' }
// fullName.value
// Output: Ada Lovelace
// With data.value = null
// fullName.value
// Output:Aborting with the callback argument instead
The same cleanup, registered through the watcher callback's third argument. Use this form when the cleanup has to be registered around an await — here the callback waits before fetching at all, and onWatcherCleanup would refuse to run after that await. The cancelled flag matters because aborting a controller cannot undo a setTimeout that has already resolved, so the callback has to check whether it is still the current one.
// src/composables/usePoll.ts
import { ref, shallowRef, toValue, watch, type MaybeRefOrGetter } from 'vue'
export function useDelayedFetch<T>(url: MaybeRefOrGetter<string>, delay: number) {
const data = shallowRef<T | null>(null)
const isLoading = ref(false)
watch(
() => toValue(url),
async (target, _previous, onCleanup) => {
const controller = new AbortController()
let cancelled = false
onCleanup(() => {
cancelled = true
controller.abort()
})
await new Promise((resolve) => setTimeout(resolve, delay))
if (cancelled) return
isLoading.value = true
try {
const res = await fetch(target, { signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = (await res.json()) as T
} catch {
data.value = null
} finally {
isLoading.value = false
}
},
{ immediate: true },
)
return { data, isLoading }
}
// useDelayedFetch('/api/search?q=vu', 300) then an immediate URL change:
// the first callback resumes after its await and returns before fetching.
Discussion