Devuelve una ref error junto a data e isLoading. El composable captura los errores internamente y los expone como estado reactivo, para que el componente pueda renderizar la UI de error sin bloques try/catch en el template. Nunca dejes que los errores escapen en silencio, y nunca lances excepciones desde un composable a menos que quien lo llame lo espere explícitamente.
Patrón básico
// composables/useFetchData.ts
export function useFetchData<T>(url: string) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const isLoading = ref(false)
async function execute() {
isLoading.value = true
error.value = null
try {
data.value = await $fetch<T>(url)
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
data.value = null
} finally {
isLoading.value = false
}
}
execute()
return { data, error, isLoading, retry: execute }
}<script setup>
const { data: users, error, isLoading, retry } = useFetchData<User[]>('/api/users')
</script>
<template>
<div v-if="isLoading">Loading...</div>
<div v-else-if="error">
<p>Failed to load: {{ error.message }}</p>
<button @click="retry">Try again</button>
</div>
<ul v-else-if="users">
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</template>El componente gestiona los tres estados (carga, error, éxito) de forma declarativa. La función retry permite al usuario recuperarse de fallos transitorios.
Por qué no lanzar excepciones
Si el composable lanza una excepción, el error se propaga hacia arriba y hace fallar el setup del componente. No hay nada que lo capture a menos que el componente envuelva la llamada en try/catch, lo que anula el propósito de que el composable abstraiga la lógica asíncrona:
// MAL: lanzar desde un composable
export function useFetchData<T>(url: string) {
const data = ref<T | null>(null)
onMounted(async () => {
data.value = await $fetch<T>(url) // lanza en error — hace fallar el componente
})
return { data }
}Devolver una ref error le da al consumidor control total sobre cómo mostrar el error.
Observar URLs reactivas
Cuando la URL depende de estado reactivo, vuelve a cargar en cada cambio y gestiona los errores para cada petición:
export function useFetchData<T>(url: MaybeRefOrGetter<string>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const isLoading = ref(false)
async function execute() {
const resolvedUrl = toValue(url)
isLoading.value = true
error.value = null
try {
data.value = await $fetch<T>(resolvedUrl)
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
data.value = null
} finally {
isLoading.value = false
}
}
watch(() => toValue(url), execute, { immediate: true })
return { data, error, isLoading, retry: execute }
}<script setup>
const userId = ref(1)
const { data: user, error } = useFetchData<User>(
() => `/api/users/${userId.value}`
)
</script>Cada vez que userId cambia, el composable carga la nueva URL y resetea el estado de error.
Errores tipados para distintos tipos de fallo
Diferencia entre errores de red, errores de validación y errores de lógica de negocio:
interface FetchResult<T> {
data: Ref<T | null>
error: Ref<FetchError | null>
isLoading: Ref<boolean>
retry: () => Promise<void>
}
interface FetchError {
message: string
status?: number
isNetworkError: boolean
isValidationError: boolean
}
function toFetchError(e: unknown): FetchError {
if (e instanceof Response || (e && typeof e === 'object' && 'status' in e)) {
const status = (e as any).status
return {
message: `Request failed with status ${status}`,
status,
isNetworkError: false,
isValidationError: status === 422
}
}
return {
message: e instanceof Error ? e.message : String(e),
isNetworkError: true,
isValidationError: false
}
}<template>
<div v-if="error?.isNetworkError">
Check your connection.
<button @click="retry">Retry</button>
</div>
<div v-else-if="error?.isValidationError">
The submitted data was invalid.
</div>
<div v-else-if="error">
Something went wrong: {{ error.message }}
</div>
</template>Gestión global de errores con onErrorCaptured
Para errores que los composables no pueden gestionar (errores de ejecución inesperados), usa onErrorCaptured en un componente padre:
<!-- ErrorBoundary.vue -->
<script setup>
const error = ref<Error | null>(null)
onErrorCaptured((err) => {
error.value = err
return false
})
</script>
<template>
<div v-if="error">
<p>Something went wrong: {{ error.message }}</p>
<button @click="error = null">Dismiss</button>
</div>
<slot v-else />
</template><!-- Uso -->
<ErrorBoundary>
<UserProfile :user-id="1" />
</ErrorBoundary>Esto captura cualquier error lanzado durante el renderizado o los lifecycle hooks de componentes hijos, evitando que toda la aplicación falle.
Lista de comprobación
| Práctica | Por qué |
|---|---|
Devuelve ref error, no lances excepciones | El consumidor controla el renderizado del error |
| Resetea el error antes de cada petición | Los errores obsoletos no persisten entre reintentos |
Expone una función retry | Permite a los usuarios recuperarse de fallos transitorios |
| Tipifica los errores por categoría | Diferentes errores necesitan diferente UI |
Usa onErrorCaptured para errores inesperados | Evita fallos totales de la aplicación |