Vue 3 Composableエラーバウンダリ:レジリエントコンポーネントエラー処理の5つのコアパターン
はじめに
このような場面に遭遇したことはありませんか?子コンポーネントが例外をスローしてアプリ全体が白画面クラッシュ、非同期リクエストのエラーがコンポーネントツリーでキャッチされない、ユーザーに表示されるエラー情報が晦渋なスタックトレースでフレンドリーではない、エラー発生後に自動リカバリできずページリフレッシュしかない。Vue 3はonErrorCapturedフックを提供していますが、完全なエラーバウンダリシステムの構築は依然として開発者の課題です。本記事では5つのコアパターンを通じて、レジリエントコンポーネントエラー処理ソリューションを構築します。
コア概念クイックリファレンス
| 概念 | 説明 |
|---|---|
| Error Boundary | エラーバウンダリ、子コンポーネントツリーのエラーをキャッチするコンポーネントパターン |
| onErrorCaptured | Vue 3ライフサイクルフック、子孫コンポーネントのエラーをキャッチ |
| Composableエラー処理 | コンポーザブル関数にエラーキャッチとリカバリロジックをカプセル化 |
| グローバルエラーハンドラー | app.config.errorHandler、アプリレベルの未処理エラーをキャッチ |
| グレースフルデグラデーション | エラー発生時にクラッシュではなくフォールバックUIを表示 |
| エラーレポート | ランタイムエラーをモニタリングサービスに送信 |
| リトライメカニズム | エラー発生後に自動または手動で操作をリトライ |
| Suspenseエラー | 非同期コンポーネント読み込み失敗時のエラー処理 |
問題分析:エラー処理の5つの課題
1. コンポーネントエラーによるアプリクラッシュ:Vueはデフォルトで未キャッチのエラーを上方に伝播し、最終的にアプリ全体が白画面に。
2. 非同期エラーのキャッチ不可:onErrorCapturedはsetTimeoutやfetchなどの非同期コールバック内のエラーをキャッチできない。
3. フレンドリーでないエラーメッセージ:ネイティブのエラースタックはユーザーにとって無意味、理解可能なUIプロンプトに変換が必要。
4. エラーリカバリの困難さ:エラー発生後にリトライやリセットなどのリカバリメカニズムがなく、ユーザーはページリフレッシュしかできない。
5. エラートラッキングの欠如:プロダクション環境でエラー情報が消失、リモートモニタリングとトラブルシューティングが不可能。
パターン1:onErrorCapturedコンポーネントエラーキャッチ
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 }
}
// コンポーネントでの使用
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' }, `コンポーネントレンダーエラー:${error.value?.message}`),
h('button', { class: 'retry-btn', onClick: reset }, 'リトライ'),
])
}
return slots.default?.()
}
},
})
パターン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,
}
}
パターン3:非同期エラー処理と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エラー処理
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, '非同期読み込みに失敗しました'),
h('button', { onClick: retry }, '再読み込み'),
])
}
return slots.default?.()
}
},
})
パターン4:グローバルエラー処理とレポート
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' })
})
}
}
パターン5:プロダクション級エラーリカバリフレームワーク
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,
}
}
落とし穴ガイド:5つのよくある罠
1. ❌ onErrorCapturedでfalseを返さない → ✅ falseを返してエラーの上方伝播を停止、そうしないとアプリがクラッシュ
2. ❌ 非同期コールバックのエラーをキャッチしようとする → ✅ onErrorCapturedは同期エラーのみキャッチ、非同期エラーはtry-catchまたはwindow.unhandledrejectionが必要
3. ❌ エラーバウンダリコンポーネント自体もエラーをスロー → ✅ エラーバウンダリコンポーネントは最小限に、フォールバックUIに複雑なロジックを避ける
4. ❌ グローバルerrorHandlerがすべてのエラーを飲み込む → ✅ グローバルハンドラーはレポート+記録すべき、黙って無視するとデバッグが困難に
5. ❌ Promiseリジェクションを無視 → ✅ unhandledrejectionイベントをリッスンして未処理のPromiseリジェクションをキャッチ
エラートラブルシューティング:10のよくあるエラー
| エラーメッセージ | 原因 | 解決策 |
|---|---|---|
Uncaught TypeError |
コンポーネント内の未キャッチランタイムエラー | onErrorCapturedまたはグローバルerrorHandlerを使用 |
Unhandled Promise Rejection |
async関数でtry-catchなし | useAsyncHandlerで非同期操作をラップ |
Maximum call stack size exceeded |
エラーリカバリロジックによる無限ループ | リトライ回数を制限、上限を追加 |
Cannot read properties of undefined |
レンダリング時に未初期化のリアクティブデータにアクセス | オプショナルチェーンまたはデフォルト値を追加 |
onErrorCaptured not triggered |
エラーが非同期コールバックで発生 | 非同期エラーは手動でtry-catchする必要がある |
Component rendering error |
テンプレートでnull/undefinedを参照 | v-ifガードまたはデフォルト値を使用 |
Network request failed |
APIリクエストのエラーステータス未処理 | fetch/axiosにエラー処理を追加 |
ChunkLoadError |
デプロイ後の古いchunkファイルが存在しない | バージョン検出を追加、自動ページリフレッシュ |
Hydration mismatch |
SSRエラーバウンダリがクライアントと不一致 | SSRとCSRでエラーバウンダリの動作が一致することを確認 |
Error boundary itself crashed |
フォールバックコンポーネントも例外をスロー | フォールバックを最小限に、複雑なロジックを回避 |
高度なテクニック
1. エラー分類と重大度:エラータイプ(ネットワーク/レンダー/ロジック)と重大度(致命的/警告/情報)に基づいて異なるリカバリ戦略を策定。
2. エラー重複排除と集約:同じエラーを重複排除し、短時間に同じエラーの重複レポートを回避。
3. ユーザーフィードバック統合:エラー発生時にフィードバックフォームを表示、ユーザーの操作手順を収集して問題再現を支援。
4. エラーバウンダリネスト:コンポーネントツリーの異なるレベルにエラーバウンダリを設定、グローバルクラッシュではなく局所的デグラデーションを実現。
5. SourceMap逆解決:プロダクション環境でSourceMapをモニタリングプラットフォームにアップロード、ミニファイされたエラースタックをソースコード位置に復元。
比較分析:Vue onErrorCaptured vs React ErrorBoundary vs Svelteバウンダリ vs グローバルtry-catch
| 特徴 | Vue onErrorCaptured | React ErrorBoundary | Svelteバウンダリ | グローバルtry-catch |
|---|---|---|---|---|
| キャッチ範囲 | 子孫コンポーネントエラー | 子孫コンポーネントエラー | 手動実装が必要 | 任意のコードブロック |
| 非同期エラー | ❌ 非対応 | ❌ 非対応 | ❌ 非対応 | ✅ 対応 |
| イベントハンドラエラー | ✅ 対応 | ✅ 対応 | ⚠️ 手動 | ✅ 対応 |
| フォールバックUI | ✅ カスタム | ✅ カスタム | ✅ カスタム | ⚠️ 限定 |
| エラーリカバリ | ✅ 状態リセット | ✅ 状態リセット | ⚠️ 手動 | ✅ リトライロジック |
| ネスト対応 | ✅ ネイティブ | ✅ ネイティブ | ⚠️ 手動 | ✅ コードブロックネスト |
| 学習曲線 | 低 | 低 | 低 | 最低 |
オンラインツールおすすめ
- JSONフォーマッター — エラーレポートのJSONデータをフォーマットして確認、エラー詳細を素早く特定
- ハッシュエンコードツール — エラーフィンガープリントにユニークハッシュを生成し、エラー重複排除を実現
- cURL→コード変換ツール — エラーレポートAPIリクエストをコードに素早く変換
まとめと展望
Vue 3 Composableエラーバウンダリは、レジリエントアプリケーションを構築するための重要なインフラです。5つのコアパターン——onErrorCapturedコンポーネントエラーキャッチ、useErrorBoundary Composable、非同期エラーとSuspense処理、グローバルエラー処理とレポート、プロダクション級エラーリカバリフレームワーク——を通じて、アプリは例外に直面した際にクラッシュではなくグレースフルにデグラデーションできます。2026年、Vue 3.5+の改善によりエラーバウンダリとSuspenseの統合はさらに密接になり、Vapor Mode下のエラー処理メカニズムもさらに効率的になるでしょう。
関連記事
- Vue 3 Error Handling — Vue 3 onErrorCaptured公式ドキュメント
- React Error Boundaries — Reactエラーバウンダリリファレンス
- Sentry Vue Integration — Sentry Vueエラーモニタリング統合
- Web Error Reporting API — ブラウザエラーレポートAPI
- Source Map Specification — SourceMap仕様と逆解決原理
ブラウザローカルツールを無料で試す →