第8章 Error Boundary
エラーハンドリング
前章では、コンポーネントツリーを超えたデータ共有を実現する「Context」を実装しました。この章では、コンポーネントでエラーが発生した場合にフォールバックUIを表示する「Error Boundary」を実装していきます。
Error Boundaryとは何か
Error Boundaryは、子コンポーネントツリーで発生したJavaScriptエラーをキャッチし、エラーが発生した部分の代わりにフォールバックUIを表示するコンポーネントです。これにより、アプリケーション全体がクラッシュすることを防ぎ、ユーザー体験を向上させることができます。
Error Boundaryの特徴
Error Boundaryには以下のような特徴があります:
- 部分的なエラー処理: アプリケーション全体ではなく、エラーが発生した部分だけを隔離
- 宣言的なエラーハンドリング: try-catchのような命令型ではなく、宣言的なエラー処理
- 階層的な適用: 複数のError Boundaryを異なるレベルで配置可能
- フォールバックUI: エラー発生時に代替UIを表示
関数コンポーネントでのError Boundary実装
通常、Error BoundaryはReactのライフサイクルメソッドcomponentDidCatchとgetDerivedStateFromErrorを使用するため、クラスコンポーネントで実装されます。しかし、このプロジェクトでは関数コンポーネントのみを使用するため、フックとコンテキストを組み合わせた独自のError Boundary実装を行います。
1. 型定義
まず、Error Boundaryに必要な型を定義します:
// error-boundary/types.ts
// エラー状態の型
export interface ErrorState {
hasError: boolean;
error: Error | null;
}
// エラーハンドラーの型
export interface ErrorHandler {
onError: (error: Error, errorInfo: { componentStack: string }) => void;
}
// Error Boundaryのプロパティ型
export interface ErrorBoundaryProps {
fallback: (error: Error) => any;
onError?: (error: Error, errorInfo: { componentStack: string }) => void;
children: any;
}2. エラーコンテキストの作成
エラー状態を管理するためのコンテキストを作成します:
// error-boundary/context.ts
import { createContext } from '../context/createContext';
import { ErrorState, ErrorHandler } from './types';
// 初期エラー状態
export const initialErrorState: ErrorState = {
hasError: false,
error: null
};
// エラー状態コンテキスト
export const ErrorStateContext = createContext<ErrorState>(initialErrorState);
// エラーハンドラーコンテキスト
export const ErrorHandlerContext = createContext<ErrorHandler>({
onError: () => {}
});3. Error Boundary実装
次に、Error Boundaryコンポーネントを実装します:
// error-boundary/boundary.ts
import { createElement } from '../createElement';
import { useState, useEffect } from '../hooks';
import { ErrorStateContext, ErrorHandlerContext, initialErrorState } from './context';
import { ErrorBoundaryProps } from './types';
// エラーをキャッチする関数
const captureError = (
error: Error,
errorInfo: { componentStack: string },
setErrorState: (state: any) => void,
onError?: (error: Error, errorInfo: { componentStack: string }) => void
) => {
// エラー状態を更新
setErrorState({
hasError: true,
error
});
// エラーハンドラーを呼び出し
if (onError) {
onError(error, errorInfo);
}
};
// Error Boundaryコンポーネント
export const ErrorBoundary = (props: ErrorBoundaryProps) => {
const { fallback, onError, children } = props;
// エラー状態
const [errorState, setErrorState] = useState(initialErrorState);
// エラーハンドラー
const errorHandler = {
onError: (error: Error, errorInfo: { componentStack: string }) => {
captureError(error, errorInfo, setErrorState, onError);
}
};
// グローバルエラーハンドリング
useEffect(() => {
const handleError = (event: ErrorEvent) => {
event.preventDefault();
captureError(
event.error || new Error('Unknown error'),
{ componentStack: '' },
setErrorState,
onError
);
};
// エラーイベントリスナーを登録
window.addEventListener('error', handleError);
// クリーンアップ
return () => {
window.removeEventListener('error', handleError);
};
}, []);
// エラーが発生した場合はフォールバックUIを表示
if (errorState.hasError) {
return fallback(errorState.error!);
}
// エラーがない場合は子要素を表示
return createElement(
ErrorHandlerContext.Provider,
{ value: errorHandler },
createElement(
ErrorStateContext.Provider,
{ value: errorState },
children
)
);
};4. エラーバウンダリーフックの実装
コンポーネント内でエラーを投げるためのフックを実装します:
// error-boundary/useErrorBoundary.ts
import { useContext } from '../context/useContext';
import { ErrorHandlerContext } from './context';
// エラーを投げるフック
export function useErrorBoundary() {
const errorHandler = useContext(ErrorHandlerContext);
// エラーを投げる関数
const throwError = (error: Error) => {
errorHandler.onError(error, { componentStack: new Error().stack || '' });
throw error;
};
return { throwError };
}テストの作成
テスト駆動開発のアプローチに従い、Error Boundaryのテストを作成します:
import { createElement } from '../src/createElement';
import { createFiberRoot } from '../src/fiber/core';
import { scheduleWork } from '../src/fiber/update';
import { ErrorBoundary } from '../src/error-boundary/boundary';
import { useErrorBoundary } from '../src/error-boundary/useErrorBoundary';
describe('error boundary', () => {
beforeEach(() => {
// テスト用のDOM環境をリセット
document.body.innerHTML = '';
jest.useFakeTimers();
// コンソールエラーを抑制
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('エラーが発生した場合にフォールバックUIを表示すること', () => {
const container = document.createElement('div');
document.body.appendChild(container);
// エラーを投げるコンポーネント
const BuggyComponent = () => {
throw new Error('テストエラー');
return createElement('div', null, 'このコンポーネントは表示されません');
};
// フォールバックコンポーネント
const Fallback = (error: Error) => {
return createElement('div', { className: 'error' }, `エラーが発生しました: ${error.message}`);
};
// Error Boundaryでラップ
const App = () => {
return createElement(
ErrorBoundary,
{ fallback: Fallback },
createElement(BuggyComponent)
);
};
// レンダリング
const fiberRoot = createFiberRoot(container);
scheduleWork(fiberRoot, createElement(App));
jest.runAllTimers();
// フォールバックUIが表示されていることを確認
const errorElement = container.querySelector('.error');
expect(errorElement).not.toBeNull();
expect(errorElement?.textContent).toBe('エラーが発生しました: テストエラー');
});
it('useErrorBoundaryフックを使ってエラーを投げられること', () => {
const container = document.createElement('div');
document.body.appendChild(container);
// エラーを投げるコンポーネント
const ConditionalError = ({ shouldThrow }: { shouldThrow: boolean }) => {
const { throwError } = useErrorBoundary();
if (shouldThrow) {
throwError(new Error('条件付きエラー'));
}
return createElement('div', { className: 'success' }, '正常に表示されています');
};
// フォールバックコンポーネント
const Fallback = (error: Error) => {
return createElement('div', { className: 'error' }, `エラーが発生しました: ${error.message}`);
};
// Error Boundaryでラップ
const App = ({ shouldThrow }: { shouldThrow: boolean }) => {
return createElement(
ErrorBoundary,
{ fallback: Fallback },
createElement(ConditionalError, { shouldThrow })
);
};
// 正常なケース
const fiberRoot = createFiberRoot(container);
scheduleWork(fiberRoot, createElement(App, { shouldThrow: false }));
jest.runAllTimers();
// 正常に表示されていることを確認
expect(container.querySelector('.success')).not.toBeNull();
expect(container.querySelector('.error')).toBeNull();
// エラーを投げるケース
scheduleWork(fiberRoot, createElement(App, { shouldThrow: true }));
jest.runAllTimers();
// フォールバックUIが表示されていることを確認
expect(container.querySelector('.success')).toBeNull();
expect(container.querySelector('.error')).not.toBeNull();
expect(container.querySelector('.error')?.textContent).toBe('エラーが発生しました: 条件付きエラー');
});
it('onErrorコールバックが呼び出されること', () => {
const container = document.createElement('div');
document.body.appendChild(container);
// エラーハンドラーモック
const handleError = jest.fn();
// エラーを投げるコンポーネント
const BuggyComponent = () => {
throw new Error('コールバックテストエラー');
return createElement('div', null, 'このコンポーネントは表示されません');
};
// フォールバックコンポーネント
const Fallback = (error: Error) => {
return createElement('div', { className: 'error' }, `エラーが発生しました: ${error.message}`);
};
// Error Boundaryでラップ
const App = () => {
return createElement(
ErrorBoundary,
{
fallback: Fallback,
onError: handleError
},
createElement(BuggyComponent)
);
};
// レンダリング
const fiberRoot = createFiberRoot(container);
scheduleWork(fiberRoot, createElement(App));
jest.runAllTimers();
// エラーハンドラーが呼び出されたことを確認
expect(handleError).toHaveBeenCalledTimes(1);
expect(handleError.mock.calls[0][0].message).toBe('コールバックテストエラー');
});
it('ネストしたError Boundaryが正しく動作すること', () => {
const container = document.createElement('div');
document.body.appendChild(container);
// エラーを投げるコンポーネント
const BuggyComponent = () => {
throw new Error('ネストテストエラー');
return createElement('div', null, 'このコンポーネントは表示されません');
};
// 正常なコンポーネント
const NormalComponent = () => {
return createElement('div', { className: 'normal' }, '正常なコンポーネント');
};
// フォールバックコンポーネント
const InnerFallback = (error: Error) => {
return createElement('div', { className: 'inner-error' }, `内部エラー: ${error.message}`);
};
const OuterFallback = (error: Error) => {
return createElement('div', { className: 'outer-error' }, `外部エラー: ${error.message}`);
};
// ネストしたError Boundary
const App = () => {
return createElement(
ErrorBoundary,
{ fallback: OuterFallback },
createElement(NormalComponent),
createElement(
ErrorBoundary,
{ fallback: InnerFallback },
createElement(BuggyComponent)
)
);
};
// レンダリング
const fiberRoot = createFiberRoot(container);
scheduleWork(fiberRoot, createElement(App));
jest.runAllTimers();
// 内部のError Boundaryがエラーをキャッチし、外部のコンポーネントは正常に表示されていることを確認
expect(container.querySelector('.normal')).not.toBeNull();
expect(container.querySelector('.inner-error')).not.toBeNull();
expect(container.querySelector('.outer-error')).toBeNull();
expect(container.querySelector('.inner-error')?.textContent).toBe('内部エラー: ネストテストエラー');
});
});Error Boundaryの動作原理
Error Boundaryがどのように動作するかを図示します:
実際の使用例
Error Boundaryを使用して、エラーに強いアプリケーションを実装してみましょう:
import { createElement } from './createElement';
import { createFiberRoot } from './fiber/core';
import { scheduleWork } from './fiber/update';
import { ErrorBoundary } from './error-boundary/boundary';
import { useErrorBoundary } from './error-boundary/useErrorBoundary';
import { useState } from './hooks/useState';
// データ取得をシミュレートする関数
const fetchData = (id: number): Promise<{ name: string, value: number }> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id <= 0) {
reject(new Error('無効なID'));
} else {
resolve({
name: `アイテム ${id}`,
value: id * 10
});
}
}, 1000);
});
};
// データ表示コンポーネント
const DataDisplay = ({ id }: { id: number }) => {
const [data, setData] = useState<{ name: string, value: number } | null>(null);
const [loading, setLoading] = useState(true);
const { throwError } = useErrorBoundary();
// データ取得
useState(() => {
setLoading(true);
fetchData(id)
.then(result => {
setData(result);
setLoading(false);
})
.catch(error => {
setLoading(false);
throwError(error);
});
}, [id]);
// ローディング中
if (loading) {
return createElement('div', { className: 'loading' }, 'データを読み込み中...');
}
// データ表示
return createElement(
'div',
{ className: 'data-display' },
createElement('h3', null, data?.name),
createElement('p', null, `値: ${data?.value}`)
);
};
// エラーフォールバックコンポーネント
const ErrorFallback = (error: Error) => {
return createElement(
'div',
{
className: 'error-fallback',
style: {
padding: '20px',
backgroundColor: '#ffdddd',
border: '1px solid #ff0000',
borderRadius: '4px',
margin: '10px 0'
}
},
createElement('h3', null, 'エラーが発生しました'),
createElement('p', null, error.message),
createElement('p', null, 'しばらく経ってからもう一度お試しください。')
);
};
// アプリケーションコンポーネント
const App = () => {
const [currentId, setCurrentId] = useState(1);
return createElement(
'div',
{ className: 'app' },
createElement('h1', null, 'Error Boundary デモ'),
createElement(
'div',
{ className: 'controls' },
createElement('button', { onClick: () => setCurrentId(currentId - 1) }, 'Previous'),
createElement('span', null, ` ID: ${currentId} `),
createElement('button', { onClick: () => setCurrentId(currentId + 1) }, 'Next')
),
createElement(
ErrorBoundary,
{
fallback: ErrorFallback,
onError: (error) => console.log('エラーをログに記録:', error)
},
createElement(DataDisplay, { id: currentId })
)
);
};
// アプリケーションのレンダリング
const container = document.getElementById('root');
if (container) {
const fiberRoot = createFiberRoot(container);
scheduleWork(fiberRoot, createElement(App));
}次のステップ
Error Boundaryの実装により、コンポーネントでエラーが発生した場合でもアプリケーション全体がクラッシュすることなく、適切なフォールバックUIを表示できるようになりました。次の章では、親コンポーネントのDOM階層外に子要素を描画する「Portal」を実装していきます。