Errors Across an Application

Catch errors with app.config.errorHandler and onErrorCaptured, and give the user a fallback instead of a blank screen.

SyntaxonErrorCaptured((err: unknown, instance: ComponentPublicInstance | null, info: string) => boolean | void)

An error thrown while a component renders unmounts the component tree. The user is left with a blank page and no idea what happened. Vue gives you two places to intervene, one global and one local.

The global handler

app.config.errorHandler receives every uncaught error from component renders, event handlers, lifecycle hooks, setup(), watchers, custom directive hooks and transition hooks. Its three arguments are err, the component instance or null, and info, a string naming the source. In a production build info is a short code rather than a sentence, which is why the error reference page exists. This is where reporting belongs, not recovery.

An error boundary

onErrorCaptured is a lifecycle hook that catches errors from descendant components. Its callback takes the same three arguments and may return a boolean. Return false and the error stops there: no parent hook and no global handler sees it. Return nothing and it keeps travelling, bottom to top, like a bubbling DOM event. A boundary component wraps a region, keeps the caught error in a ref, and renders a fallback instead of that region.

What trips people up

A boundary cannot catch its own render error, only a descendant's, so it must sit above the risky part of the tree. Vue also sees nothing thrown from a setTimeout callback or from a promise nobody awaits; those need window listeners. The example draws its slot content normally, and on failure shows a message with a button that clears the error and tries again.

Example

Example · vue
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'

const props = defineProps<{ label: string }>()
const emit = defineEmits<{ captured: [error: unknown, info: string] }>()

const error = ref<unknown>(null)

onErrorCaptured((err, _instance, info) => {
  error.value = err
  emit('captured', err, info)
  return false
})

function retry() {
  error.value = null
}
</script>

<template>
  <div v-if="error" role="alert">
    <slot name="fallback" :retry="retry">
      <p>{{ props.label }} could not be shown.</p>
      <button type="button" @click="retry">Try again</button>
    </slot>
  </div>
  <slot v-else />
</template>

<style scoped>
div {
  border: 1px solid currentColor;
  padding: 1rem;
}
</style>

When to use it

  • Keeping a dashboard usable when one widget throws instead of blanking the page.
  • Sending render errors to a reporting service with the component source attached.
  • Giving a failed region a retry button rather than asking the user to reload.

More examples

Reporting every error once

app.config.errorHandler is set on the application instance before mount. It logs in development and hands the error to your reporting service in production, with the info string attached so you know whether it came from a render, a watcher or an event handler. Keep it free of UI work: it runs while the tree is already failing.

Example · ts
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { report } from '@/shared/report'

const app = createApp(App)

app.config.errorHandler = (err, instance, info) => {
  if (import.meta.env.DEV) {
    console.error(`[vue] ${info}`, err, instance)
    return
  }
  report(err, { source: info })
}

app.use(createPinia())
app.use(router)
app.mount('#app')

Using the boundary around one region

The boundary wraps only the part that can fail, so a broken chart does not take the page with it. The default slot is the content; the fallback slot is what replaces it, and it receives retry so the markup can place the button itself. Note that <slot /> and <slot name="default" /> mean the same thing.

Example · vue
<script setup lang="ts">
import ErrorBoundary from '@/shared/components/ErrorBoundary.vue'
import RevenueChart from '@/features/reports/components/RevenueChart.vue'
import { report } from '@/shared/report'

function onCaptured(error: unknown, info: string) {
  report(error, { source: info, region: 'revenue chart' })
}
</script>

<template>
  <h1>Reports</h1>

  <error-boundary label="The revenue chart" @captured="onCaptured">
    <revenue-chart period="quarter" />

    <template #fallback="{ retry }">
      <p>Revenue is unavailable right now.</p>
      <button type="button" @click="retry">Reload the chart</button>
    </template>
  </error-boundary>
</template>

<style scoped>
h1 {
  margin-bottom: 1rem;
}
</style>

The errors Vue never sees

Vue's handlers only cover code it called. A rejected promise with no catch, or a throw inside a timer, reaches the window instead. Register these listeners alongside the Vue handler so one report feed covers both, and call it from main.ts before mounting.

Example · ts
// src/shared/report.ts
interface ReportContext {
  source?: string
  region?: string
}

export function report(error: unknown, context: ReportContext = {}): void {
  const message = error instanceof Error ? error.message : String(error)
  void fetch('/api/client-errors', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ message, ...context })
  })
}

export function listenForUnhandled(): void {
  window.addEventListener('unhandledrejection', (event) => {
    report(event.reason, { source: 'unhandledrejection' })
  })
  window.addEventListener('error', (event) => {
    report(event.error ?? event.message, { source: 'window.error' })
  })
}

// Output: report(new Error('boom')) posts { message: 'boom' }
// Output: report('boom', { source: 'watcher' }) posts { message: 'boom', source: 'watcher' }

Discussion

  • Be the first to comment on this lesson.