Data fetching is one of the first things you'll extract into a composable. Every component that loads data from an API repeats the same pattern: a loading flag, an error state, the actual data, and the fetch logic. A useFetch composable wraps all of that into a reusable function.
Basic implementation
import { ref, toValue, watchEffect, type MaybeRefOrGetter } from 'vue'
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const data = ref<T | null>(null)
const error = ref<string | null>(null)
const loading = ref(false)
async function execute() {
loading.value = true
error.value = null
try {
const response = await fetch(toValue(url))
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading.value = false
}
}
watchEffect(() => {
execute()
})
return { data, error, loading, refetch: execute }
}Key design decisions:
MaybeRefOrGetter<string>: the URL can be a plain string, a ref, or a getter.toValue()unwraps whatever it is. This is the Vue 3.3+ convention for composable inputs.watchEffect: runs immediately (fetches on creation) and re-runs whenever the URL changes. If the URL is static, it fetches once.- Returns refs: the caller gets reactive
data,error, andloadingthat update as the request progresses. refetch: exposes the execute function so the caller can retry or refresh manually.
Using it in a component
<script setup lang="ts">
import { computed } from 'vue'
import { useFetch } from '@/composables/useFetch'
const props = defineProps<{ userId: number }>()
const {
data: user,
error,
loading
} = useFetch<User>(() => `/api/users/${props.userId}`)
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else-if="user">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
</template>Passing a getter (() => /api/users/${props.userId}) means the composable refetches automatically when userId changes.
What about AbortController?
A production composable should cancel in-flight requests when the URL changes or the component unmounts. See How do you use AbortController in a composable? for that pattern.
When to use a library instead
For real apps, consider VueUse's useFetch or dedicated data-fetching libraries like Pinia Colada or TanStack Query. They handle caching, deduplication, retry logic, and stale-while-revalidate patterns that a simple composable doesn't cover.
See also: What is a composable? · How do you use AbortController in a composable? · What is VueUse?
References
- Composables - Vue.js docs
- Async State Example - Vue.js docs
- toValue() - Vue.js docs