Return an error ref alongside data and isLoading. The composable catches errors internally and exposes them as reactive state, so the component can render error UI without try/catch blocks in the template. Never let errors escape silently, and never throw from a composable unless the caller explicitly expects it.
Basic pattern
// 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>The component handles three states (loading, error, success) declaratively. The retry function lets the user recover from transient failures.
Why not throw?
If the composable throws, the error propagates up and crashes the component's setup. There's nothing to catch it unless the component wraps the call in try/catch, which defeats the purpose of the composable abstracting async logic:
// BAD: throwing from a composable
export function useFetchData<T>(url: string) {
const data = ref<T | null>(null)
onMounted(async () => {
data.value = await $fetch<T>(url) // throws on error — crashes the component
})
return { data }
}Returning an error ref gives the consumer full control over how to display the error.
Watching reactive URLs
When the URL depends on reactive state, re-fetch on change and handle errors for each request:
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>Each time userId changes, the composable fetches the new URL and resets the error state.
Typed errors for different failure modes
Differentiate between network errors, validation errors, and business logic errors:
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>Global error handling with onErrorCaptured
For errors that composables can't handle (unexpected runtime errors), use onErrorCaptured in a parent component:
<!-- 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><!-- Usage -->
<ErrorBoundary>
<UserProfile :user-id="1" />
</ErrorBoundary>This catches any error thrown during rendering or lifecycle hooks in child components, preventing the entire app from crashing.
Checklist
| Practice | Why |
|---|---|
Return error ref, don't throw | Consumer controls error rendering |
| Reset error before each request | Stale errors don't persist through retries |
Expose a retry function | Lets users recover from transient failures |
| Type errors by category | Different errors need different UI |
Use onErrorCaptured for unexpected errors | Prevents full app crashes |