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
  1. JSON Formatter — Format and inspect error reporting JSON data to quickly locate error details
  2. Hash Encoding Tool — Generate unique hashes for error fingerprints to implement error deduplication
  3. 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

  1. Vue 3 Error Handling — Vue 3 onErrorCaptured official docs
  2. React Error Boundaries — React error boundary reference
  3. Sentry Vue Integration — Sentry Vue error monitoring integration
  4. Web Error Reporting API — Browser error reporting API
  5. Source Map Specification — SourceMap specification and reverse resolution

Try these browser-local tools — no sign-up required →

#Vue3错误边界#Composable#错误处理#异常捕获#2026#前端工程