Skip to content
← All questions
⚡ Intermediate

How do you handle errors in async composables?

🪝 Composables🛡️ Error Handling👁️ Watchers

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 ​

ts
// 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 }
}
vue
<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>
Open in Vue Playground

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:

ts
// 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:

ts
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 }
}
vue
<script setup>
const userId = ref(1)
const { data: user, error } =
  useFetchData < User > (() => `/api/users/${userId.value}`)
</script>
Open in Vue Playground

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:

ts
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
  }
}
vue
<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>
Open in Vue Playground

Global error handling with onErrorCaptured ​

For errors that composables can't handle (unexpected runtime errors), use onErrorCaptured in a parent component:

vue
<!-- 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>
Open in Vue Playground
vue
<!-- Usage -->
<ErrorBoundary>
  <UserProfile :user-id="1" />
</ErrorBoundary>
Open in Vue Playground

This catches errors from descendant components thrown during: renders, watchers, lifecycle hooks, event handlers, setup(), custom directive hooks, and transition hooks. It prevents the entire app from crashing.

Checklist ​

PracticeWhy
Return error ref, don't throwConsumer controls error rendering
Reset error before each requestStale errors don't persist through retries
Expose a retry functionLets users recover from transient failures
Type errors by categoryDifferent errors need different UI
Use onErrorCaptured for unexpected errorsPrevents full app crashes

See also: How would you build a composable for data fetching? · What is a composable? · How does error handling work in Vue?

References ​

Released under the MIT License.