Vue 3 Composable Error Boundary: 5 Core Patterns for Resilient Component Error Handling
Introduction
Have you encountered these scenarios: a child component throwing an exception that crashes the entire app with a white screen, async request errors invisible to the component tree, users seeing cryptic stack traces instead of friendly messages, and no automatic recovery after errors forcing users to refresh? Vue 3 provides the onErrorCaptured hook, but building a complete error boundary system remains a pain point. This article walks you through 5 core patterns to build resilient component error handling.
Core Concepts at a Glance
| Concept | Description |
|---|---|
| Error Boundary | A component pattern that catches errors in child component trees |
| onErrorCaptured | Vue 3 lifecycle hook that captures descendant component errors |
| Composable Error Handling | Encapsulating error capture and recovery logic in composable functions |
| Global Error Handler | app.config.errorHandler, catching application-level unhandled errors |
| Graceful Degradation | Showing fallback UI instead of crashing when errors occur |
| Error Reporting | Sending runtime errors to monitoring services |
| Retry Mechanism | Automatically or manually retrying operations after errors |
| Suspense Errors | Error handling for async component loading failures |
Problem Analysis: 5 Major Challenges with Error Handling
1. Component Errors Crash the App: Vue propagates uncaught errors upward by default, eventually causing the entire app to white-screen.
2. Async Errors Cannot Be Captured: onErrorCaptured cannot catch errors in setTimeout, fetch, and other async callbacks.
3. Unfriendly Error Messages: Native error stacks are meaningless to users — they need to be converted into understandable UI prompts.
4. Difficult Error Recovery: After errors occur, there are no retry or reset mechanisms — users can only refresh the page.
5. Missing Error Tracking: Error information is lost in production, making remote monitoring and troubleshooting impossible.
Pattern 1: onErrorCaptured Component Error Catching
import { onErrorCaptured, ref, type Component } from 'vue'
interface ErrorBoundaryState {
hasError: boolean
error: Error | null
errorInfo: string | null
}
export function useErrorCapture() {
const state = ref<ErrorBoundaryState>({
hasError: false,
error: null,
errorInfo: null,
})
onErrorCaptured((err, instance, info) => {
state.value = {
hasError: true,
error: err as Error,
errorInfo: info,
}
console.error('[ErrorBoundary]', err, info)
return false
})
const reset = () => {
state.value = { hasError: false, error: null, errorInfo: null }
}
return { ...toRefs(state.value), reset }
}
// Usage in component
export const ErrorBoundaryWrapper = defineComponent({
name: 'ErrorBoundaryWrapper',
setup(_, { slots }) {
const { hasError, error, reset } = useErrorCapture()
return () => {
if (hasError.value) {
return h('div', { class: 'error-fallback' }, [
h('p', { class: 'error-message' }, `Component render error: ${error.value?.message}`),
h('button', { class: 'retry-btn', onClick: reset }, 'Retry'),
])
}
return slots.default?.()
}
},
})
Pattern 2: useErrorBoundary Composable
import { ref, readonly, type Ref } from 'vue'
interface ErrorBoundaryOptions {
fallback?: (error: Error, reset: () => void) => VNode
onError?: (error: Error, info: string) => void
retryLimit?: number
}
interface ErrorBoundaryResult {
hasError: Readonly<Ref<boolean>>
error: Readonly<Ref<Error | null>>
retryCount: Readonly<Ref<number>>
reset: () => void
retry: () => void
}
export function useErrorBoundary(options: ErrorBoundaryOptions = {}) {
const { onError, retryLimit = 3 } = options
const hasError = ref(false)
const error = ref<Error | null>(null)
const retryCount = ref(0)
const reset = () => {
hasError.value = false
error.value = null
retryCount.value = 0
}
const retry = () => {
if (retryCount.value >= retryLimit) {
console.warn(`[ErrorBoundary] Retry limit (${retryLimit}) reached`)
return
}
retryCount.value++
hasError.value = false
error.value = null
}
onErrorCaptured((err, _instance, info) => {
hasError.value = true
error.value = err as Error
onError?.(err as Error, info)
return false
})
return {
hasError: readonly(hasError),
error: readonly(error),
retryCount: readonly(retryCount),
reset,
retry,
}
}
Pattern 3: Async Error Handling & Suspense
import { ref, onUnmounted } from 'vue'
interface AsyncState<T> {
data: T | null
error: Error | null
loading: boolean
}
export function useAsyncHandler<T>(
fn: () => Promise<T>,
options: {
immediate?: boolean
retryCount?: number
retryDelay?: number
onError?: (error: Error) => void
} = {}
) {
const { immediate = false, retryCount = 0, retryDelay = 1000, onError } = options
const state = ref<AsyncState<T>>({
data: null,
error: null,
loading: false,
})
let retriesLeft = retryCount
let timeoutId: ReturnType<typeof setTimeout> | null = null
const execute = async (): Promise<T | null> => {
state.value.loading = true
state.value.error = null
try {
const result = await fn()
state.value.data = result
state.value.loading = false
retriesLeft = retryCount
return result
} catch (err) {
const error = err as Error
state.value.error = error
state.value.loading = false
if (retriesLeft > 0) {
retriesLeft--
timeoutId = setTimeout(() => execute(), retryDelay)
} else {
onError?.(error)
}
return null
}
}
if (immediate) {
execute()
}
onUnmounted(() => {
if (timeoutId) clearTimeout(timeoutId)
})
return {
...toRefs(state.value),
execute,
reset: () => {
state.value = { data: null, error: null, loading: false }
retriesLeft = retryCount
},
}
}
// Suspense error handling
export const AsyncErrorBoundary = defineComponent({
name: 'AsyncErrorBoundary',
setup(_, { slots }) {
const { hasError, error, retry } = useErrorBoundary({
retryLimit: 3,
onError: (err) => {
reportError(err)
},
})
return () => {
if (hasError.value) {
return h('div', { class: 'async-error-fallback' }, [
h('p', null, 'Async loading failed'),
h('button', { onClick: retry }, 'Reload'),
])
}
return slots.default?.()
}
},
})
Pattern 4: Global Error Handling & Reporting
import type { App } from 'vue'
interface ErrorReporterConfig {
endpoint: string
appVersion: string
userId?: string
sampleRate?: number
maxQueueSize?: number
}
interface ErrorReport {
message: string
stack?: string
component?: string
info?: string
url: string
timestamp: number
appVersion: string
userId?: string
userAgent: string
}
export class ErrorReporter {
private queue: ErrorReport[] = []
private config: ErrorReporterConfig
private flushTimer: ReturnType<typeof setInterval> | null = null
constructor(config: ErrorReporterConfig) {
this.config = {
sampleRate: 1,
maxQueueSize: 50,
...config,
}
}
report(error: Error, info?: { component?: string; info?: string }) {
if (Math.random() > this.config.sampleRate) return
const report: ErrorReport = {
message: error.message,
stack: error.stack,
component: info?.component,
info: info?.info,
url: typeof window !== 'undefined' ? window.location.href : '',
timestamp: Date.now(),
appVersion: this.config.appVersion,
userId: this.config.userId,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
}
this.queue.push(report)
if (this.queue.length >= (this.config.maxQueueSize ?? 50)) {
this.flush()
}
}
async flush() {
if (this.queue.length === 0) return
const batch = [...this.queue]
this.queue = []
try {
await fetch(this.config.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ errors: batch }),
keepalive: true,
})
} catch {
this.queue.unshift(...batch)
}
}
start() {
this.flushTimer = setInterval(() => this.flush(), 10000)
}
stop() {
if (this.flushTimer) clearInterval(this.flushTimer)
this.flush()
}
}
export function setupGlobalErrorHandler(app: App, reporter: ErrorReporter) {
app.config.errorHandler = (err, instance, info) => {
const error = err as Error
reporter.report(error, {
component: instance?.$options?.name,
info,
})
console.error('[GlobalErrorHandler]', error, info)
}
app.config.warnHandler = (msg, _instance, trace) => {
if (import.meta.env.PROD) return
console.warn('[VueWarn]', msg, trace)
}
if (typeof window !== 'undefined') {
window.addEventListener('unhandledrejection', (event) => {
reporter.report(new Error(event.reason), { info: 'unhandledrejection' })
})
window.addEventListener('error', (event) => {
reporter.report(event.error, { info: 'window.error' })
})
}
}
Pattern 5: Production-Grade Error Recovery Framework
import { ref, readonly, type Ref } from 'vue'
interface RecoveryStrategy {
type: 'retry' | 'fallback' | 'reset' | 'redirect'
maxAttempts?: number
delay?: number
fallbackData?: unknown
redirectUrl?: string
}
interface ErrorRecoveryState {
hasError: boolean
error: Error | null
attempts: number
recovering: boolean
strategy: RecoveryStrategy | null
}
export function useErrorRecovery(
strategies: RecoveryStrategy[] = [
{ type: 'retry', maxAttempts: 3, delay: 1000 },
{ type: 'fallback', fallbackData: null },
{ type: 'reset' },
]
) {
const state = ref<ErrorRecoveryState>({
hasError: false,
error: null,
attempts: 0,
recovering: false,
strategy: null,
})
let currentStrategyIndex = 0
let timeoutId: ReturnType<typeof setTimeout> | null = null
const handleError = (error: Error) => {
state.value.hasError = true
state.value.error = error
currentStrategyIndex = 0
attemptRecovery()
}
const attemptRecovery = () => {
if (currentStrategyIndex >= strategies.length) {
state.value.recovering = false
return
}
const strategy = strategies[currentStrategyIndex]
state.value.strategy = strategy
state.value.recovering = true
switch (strategy.type) {
case 'retry':
if (state.value.attempts < (strategy.maxAttempts ?? 3)) {
timeoutId = setTimeout(() => {
state.value.attempts++
state.value.hasError = false
state.value.error = null
}, strategy.delay ?? 1000)
} else {
currentStrategyIndex++
attemptRecovery()
}
break
case 'fallback':
state.value.recovering = false
state.value.hasError = false
break
case 'reset':
state.value = {
hasError: false,
error: null,
attempts: 0,
recovering: false,
strategy: null,
}
break
case 'redirect':
if (strategy.redirectUrl && typeof window !== 'undefined') {
window.location.href = strategy.redirectUrl
}
break
}
}
const reset = () => {
if (timeoutId) clearTimeout(timeoutId)
state.value = {
hasError: false,
error: null,
attempts: 0,
recovering: false,
strategy: null,
}
currentStrategyIndex = 0
}
onErrorCaptured((err) => {
handleError(err as Error)
return false
})
onUnmounted(() => {
if (timeoutId) clearTimeout(timeoutId)
})
return {
state: readonly(state),
reset,
retry: attemptRecovery,
}
}
Pitfall Guide: 5 Common Traps
1. ❌ Not returning false in onErrorCaptured → ✅ Return false to stop error propagation, otherwise the app still crashes
2. ❌ Trying to catch errors in async callbacks → ✅ onErrorCaptured only catches sync errors; async errors need try-catch or window.unhandledrejection
3. ❌ Error boundary component itself throws errors → ✅ Keep error boundary components minimal — avoid complex logic in fallback UI
4. ❌ Global errorHandler swallowing all errors → ✅ Global handlers should report + log, not silently ignore, otherwise debugging becomes impossible
5. ❌ Ignoring Promise rejections → ✅ Listen to unhandledrejection events to catch unhandled Promise rejections
Error Troubleshooting: 10 Common Errors
| Error Message | Cause | Solution |
|---|---|---|
Uncaught TypeError |
Uncaught runtime error in component | Use onErrorCaptured or global errorHandler |
Unhandled Promise Rejection |
No try-catch in async functions | Wrap async operations with useAsyncHandler |
Maximum call stack size exceeded |
Error recovery logic causing infinite loop | Limit retry count, add retry ceiling |
Cannot read properties of undefined |
Accessing uninitialized reactive data during render | Add optional chaining or default values |
onErrorCaptured not triggered |
Error occurs in async callback | Async errors need manual try-catch |
Component rendering error |
Template references null/undefined | Use v-if guards or provide defaults |
Network request failed |
API request error status not handled | Add error handling in fetch/axios |
ChunkLoadError |
Old chunk files missing after deploy | Add version detection, auto-refresh page |
Hydration mismatch |
SSR error boundary inconsistent with client | Ensure error boundary behavior is consistent in SSR and CSR |
Error boundary itself crashed |
Fallback component also throws | Keep fallback minimal, avoid complex logic |
Advanced Tips
1. Error Classification & Severity: Develop different recovery strategies based on error type (network/render/logic) and severity (fatal/warning/info).
2. Error Deduplication & Aggregation: Deduplicate identical errors to avoid repeatedly reporting the same error within a short timeframe.
3. User Feedback Integration: Show a feedback form when errors occur, collecting user action steps to help reproduce issues.
4. Nested Error Boundaries: Set error boundaries at different levels of the component tree for localized degradation instead of global crashes.
5. SourceMap Reverse Resolution: Upload SourceMaps to monitoring platforms in production to resolve minified error stacks back to source code locations.
Comparison: Vue onErrorCaptured vs React ErrorBoundary vs Svelte Boundary vs Global try-catch
| Feature | Vue onErrorCaptured | React ErrorBoundary | Svelte Boundary | Global try-catch |
|---|---|---|---|---|
| Capture Scope | Descendant component errors | Descendant component errors | Manual implementation | Any code block |
| Async Errors | ❌ Not supported | ❌ Not supported | ❌ Not supported | ✅ Supported |
| Event Handler Errors | ✅ Supported | ✅ Supported | ⚠️ Manual | ✅ Supported |
| Fallback UI | ✅ Custom | ✅ Custom | ✅ Custom | ⚠️ Limited |
| Error Recovery | ✅ Reset state | ✅ Reset state | ⚠️ Manual | ✅ Retry logic |
| Nesting Support | ✅ Native | ✅ Native | ⚠️ Manual | ✅ Code block nesting |
| Learning Curve | Low | Low | Low | Lowest |
Recommended Online Tools
- JSON Formatter — Format and inspect error reporting JSON data to quickly locate error details
- Hash Encoding Tool — Generate unique hashes for error fingerprints to implement error deduplication
- cURL to Code Converter — Convert error reporting API requests into code
Conclusion and Outlook
Vue 3 Composable error boundaries are critical infrastructure for building resilient applications. Through 5 core patterns — onErrorCaptured component error catching, useErrorBoundary Composable, async error handling with Suspense, global error handling & reporting, and production-grade error recovery framework — you can make your app gracefully degrade instead of crashing when facing exceptions. In 2026, Vue 3.5+ improvements will make error boundary and Suspense integration even tighter, and error handling mechanisms under Vapor Mode will become more efficient.
Further Reading
- Vue 3 Error Handling — Vue 3 onErrorCaptured official docs
- React Error Boundaries — React error boundary reference
- Sentry Vue Integration — Sentry Vue error monitoring integration
- Web Error Reporting API — Browser error reporting API
- Source Map Specification — SourceMap specification and reverse resolution
Try these browser-local tools — no sign-up required →