Skip to content

第8章 Error Boundary

エラーハンドリング

前章では、コンポーネントツリーを超えたデータ共有を実現する「Context」を実装しました。この章では、コンポーネントでエラーが発生した場合にフォールバックUIを表示する「Error Boundary」を実装していきます。

Error Boundaryとは何か

Error Boundaryは、子コンポーネントツリーで発生したJavaScriptエラーをキャッチし、エラーが発生した部分の代わりにフォールバックUIを表示するコンポーネントです。これにより、アプリケーション全体がクラッシュすることを防ぎ、ユーザー体験を向上させることができます。

Error Boundaryの特徴

Error Boundaryには以下のような特徴があります:

  1. 部分的なエラー処理: アプリケーション全体ではなく、エラーが発生した部分だけを隔離
  2. 宣言的なエラーハンドリング: try-catchのような命令型ではなく、宣言的なエラー処理
  3. 階層的な適用: 複数のError Boundaryを異なるレベルで配置可能
  4. フォールバックUI: エラー発生時に代替UIを表示

関数コンポーネントでのError Boundary実装

通常、Error BoundaryはReactのライフサイクルメソッドcomponentDidCatchgetDerivedStateFromErrorを使用するため、クラスコンポーネントで実装されます。しかし、このプロジェクトでは関数コンポーネントのみを使用するため、フックとコンテキストを組み合わせた独自のError Boundary実装を行います。

1. 型定義

まず、Error Boundaryに必要な型を定義します:

typescript
// 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. エラーコンテキストの作成

エラー状態を管理するためのコンテキストを作成します:

typescript
// 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コンポーネントを実装します:

typescript
// 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. エラーバウンダリーフックの実装

コンポーネント内でエラーを投げるためのフックを実装します:

typescript
// 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のテストを作成します:

typescript
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を使用して、エラーに強いアプリケーションを実装してみましょう:

typescript
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」を実装していきます。

Released under the MIT License.