1. ホーム
  2. React

【React】Error Boundary(エラーバウンダリ)の作り方|レンダリング中のエラーをキャッチして画面の崩壊を防ぐ

Share

子コンポーネントのレンダリング中に例外が投げられると、React はそのツリーごとアンマウントしてしまい、アプリ全体が真っ白になってしまいます。ユーザーから見れば「何も表示されず操作もできない」状態で、原因も分かりません。これを防ぐのが Error Boundary(エラーバウンダリ)です。レンダリング中のエラーをキャッチし、壊れた部分だけを用意しておいたフォールバックUIに差し替えられます。この記事では、Error Boundary が今もクラスコンポーネントでしか作れない理由を押さえたうえで、基本の実装、再試行ボタン付きのフォールバックUI、どこに置くべきか、そして Error Boundary では拾えないエラーまで、初心者〜中級者向けに整理します。

レンダリング中のエラーがアプリ全体を巻き込む理由

React はコンポーネントのツリーを描画していく途中で例外が投げられると、その例外を握りつぶさずに上へ伝播させます。どこにもキャッチする仕組みがないと、React 16 以降は「壊れたUIをそのまま表示するより、まるごと外したほうが安全だ」という方針で、ルートのツリー全体をアンマウントします。結果として画面が真っ白になります。

たとえば、次のようにデータが null の可能性を考慮せずプロパティへアクセスすると、レンダリング中に例外が発生します。

BuggyProfile.tsx
type Props = { user: { name: string } | null };

function BuggyProfile({ user }: Props) {
  // user が null のときに例外が投げられる
  return <p>こんにちは、{user.name} さん</p>;
}

この BuggyProfile がページのどこか一箇所で使われているだけでも、Error Boundary で囲っていなければページ全体が消えてしまいます。特定のウィジェットの不具合が、無関係な部分まで道連れにしてしまうわけです。Error Boundary は、この巻き添えを食い止めるための境界線(バウンダリ)を引く仕組みだと考えると分かりやすいです。

Error Boundary はクラスコンポーネントで作る

最初に押さえておきたいのは、Error Boundary は現状クラスコンポーネントでしか作れないという点です。関数コンポーネントとフックには、レンダリング中のエラーを拾うための仕組みが用意されていません。Error Boundary として振る舞うには、次の2つのライフサイクルメソッドのどちらか(実務では両方)を持つクラスコンポーネントである必要があります。

メソッド役割
static getDerivedStateFromError(error)エラー発生時に呼ばれ、返した値で state を更新する。ここでフォールバックUIを表示する状態へ切り替える。レンダリング中に呼ばれるので副作用(ログ送信など)は書かない。
componentDidCatch(error, info)エラー発生時に呼ばれ、エラー内容や発生箇所(info.componentStack)を受け取る。ログの記録や監視サービスへの送信など、副作用はこちらに書く。

役割を分けて考えると、getDerivedStateFromError は「表示を切り替えるための状態更新」、componentDidCatch は「エラーを記録するための副作用」を担当します。次の節で、この2つを持つ最小の Error Boundary を実装します。

基本の Error Boundary を実装する

子要素(children)を受け取り、エラーが起きたらフォールバックを表示するクラスコンポーネントを書きます。TypeScript では Component<Props, State> のようにプロパティと状態の型を指定します。

ErrorBoundary.tsx
import { Component, type ErrorInfo, type ReactNode } from "react";

type Props = {
  children: ReactNode;
  fallback: ReactNode; // エラー時に表示する内容
};

type State = {
  hasError: boolean;
};

class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };

  // ① エラー発生時に state を更新してフォールバックへ切り替える
  static getDerivedStateFromError(_error: Error): State {
    return { hasError: true };
  }

  // ② エラー内容を記録する(副作用はここ)
  componentDidCatch(error: Error, info: ErrorInfo) {
    console.error("Error Boundary がエラーを捕捉:", error);
    console.error("発生箇所:", info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

export default ErrorBoundary;

ポイントは render の分岐です。state.hasErrortrue のあいだは fallback を、そうでなければ本来の children を返します。子コンポーネントのレンダリングで例外が投げられると、まず getDerivedStateFromError が呼ばれて hasErrortrue になり、続く再レンダリングでフォールバックが表示される、という流れです。実際に使うときは、守りたいコンポーネントを次のように囲みます。

App.tsx
import ErrorBoundary from "./ErrorBoundary";
import BuggyProfile from "./BuggyProfile";

function App() {
  return (
    <ErrorBoundary fallback={<p>プロフィールを表示できませんでした</p>}>
      <BuggyProfile user={null} />
    </ErrorBoundary>
  );
}

export default App;

これで BuggyProfile がレンダリング中に例外を投げても、ページ全体は生き残り、「プロフィールを表示できませんでした」という代わりのUIが表示されます。境界の外側にある他の要素は、いつも通り動き続けます。

フォールバックUIと再試行(reset)を用意する

ただエラー文言を出すだけでなく、ユーザーがやり直せる導線があると親切です。エラー状態を元に戻す(reset)ボタンを付けてみます。フォールバックの見た目を柔軟にするため、ここでは fallback を「エラーとリセット関数を受け取って要素を返す関数」として渡せるようにします。

ErrorBoundary.tsx
import { Component, type ErrorInfo, type ReactNode } from "react";

type FallbackRender = (args: {
  error: Error;
  reset: () => void;
}) => ReactNode;

type Props = {
  children: ReactNode;
  fallback: FallbackRender;
};

type State = {
  hasError: boolean;
  error: Error | null;
};

class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    console.error("捕捉したエラー:", error, info.componentStack);
  }

  // エラー状態を初期化して、もう一度描画を試みる
  reset = () => {
    this.setState({ hasError: false, error: null });
  };

  render() {
    if (this.state.hasError && this.state.error) {
      return this.props.fallback({
        error: this.state.error,
        reset: this.reset,
      });
    }
    return this.props.children;
  }
}

export default ErrorBoundary;

resethasErrorfalse に戻すだけの関数です。呼ばれると再レンダリングが走り、children がもう一度描画されます。使う側は次のように、エラーメッセージと「再試行」ボタンを組み立てられます。

Dashboard.tsx
import ErrorBoundary from "./ErrorBoundary";
import Widget from "./Widget";

function Dashboard() {
  return (
    <ErrorBoundary
      fallback={({ error, reset }) => (
        <div role="alert">
          <p>ウィジェットの読み込みに失敗しました。</p>
          <pre>{error.message}</pre>
          <button onClick={reset}>再試行</button>
        </div>
      )}
    >
      <Widget />
    </ErrorBoundary>
  );
}

export default Dashboard;

注意したいのは、reset はあくまでエラー状態を解除して再描画を試みるだけだという点です。エラーの原因(たとえば壊れたデータ)がそのまま残っていれば、再描画してもまた同じ例外が投げられ、すぐフォールバックに戻ります。「再試行」で本当に回復させたいなら、リセットと同時に取得しなおすなど、原因側を解消する処理も併せて用意します。

どこを囲むか──境界の粒度を決める

Error Boundary はアプリ全体を1つで囲むこともできますが、それだけだと「どこか一箇所でも壊れたら全部フォールバック」になり、境界を設けた意味が薄れます。基本の考え方は、壊れても他に影響させたくない単位ごとに、部分的に囲むことです。

たとえばダッシュボードなら、サイドバー・メイン・各ウィジェットをそれぞれ個別の Error Boundary で囲みます。こうすると、あるウィジェットが落ちてもその枠だけがフォールバックに差し替わり、他のウィジェットやナビゲーションは動き続けます。ページ全体を包む大きな境界を1つ用意しつつ、リスクの高い部分にはより内側に細かい境界を追加する、という二段構えもよく使われます。

Layout.tsx
function Layout() {
  return (
    // 外側:ページ全体の最終防衛ライン
    <ErrorBoundary fallback={() => <p>問題が発生しました</p>}>
      <Header />
      <main>
        {/* 内側:ウィジェット単位で個別に囲む */}
        <ErrorBoundary fallback={() => <p>売上を表示できません</p>}>
          <SalesWidget />
        </ErrorBoundary>
        <ErrorBoundary fallback={() => <p>通知を表示できません</p>}>
          <NotificationWidget />
        </ErrorBoundary>
      </main>
    </ErrorBoundary>
  );
}

境界は下(子孫)で発生したエラーだけを捕捉します。SalesWidget のエラーはそれを囲む内側の境界が拾い、外側までは届きません。逆に Header でエラーが起きた場合は、内側の境界は関係なく、外側の境界が受け止めます。どこで落ちても影響範囲を最小にできるよう、境界の粒度を設計するのがコツです。

Error Boundary が拾えないエラー

Error Boundary は万能ではありません。捕捉できるのは、あくまで子コンポーネントのレンダリング中・ライフサイクル中・コンストラクタで投げられたエラーです。次のようなケースは仕組み上キャッチできないため、別の方法で対処する必要があります。

イベントハンドラの中のエラー

onClick などのイベントハンドラは、レンダリングとは別のタイミングで実行されます。そのため中で例外が投げられても Error Boundary は反応しません。ボタンのクリック処理などでエラーを扱いたいときは、ハンドラの中で try...catch を使って自分で捕捉します。

SaveButton.tsx
function SaveButton() {
  const handleClick = () => {
    try {
      // 保存処理など。ここで throw しても Error Boundary は拾えない
      save();
    } catch (e) {
      // 自分で捕まえて処理する
      console.error(e);
      alert("保存に失敗しました");
    }
  };

  return <button onClick={handleClick}>保存</button>;
}

非同期処理(setTimeout・fetch など)のエラー

setTimeout のコールバックや fetch.then の中で投げられたエラーも、レンダリングの外で起きるため捕捉できません。データ取得の失敗をフォールバックUIにつなげたい場合は、エラーを状態として保持し、レンダリング側で「エラー状態なら例外を投げる」ようにすると Error Boundary に流し込めます。もしくは非同期の失敗はその場で状態にして表示を切り替える、という素直な設計にします。

サーバーサイドレンダリング(SSR)中のエラー

Error Boundary はクライアント側でツリーを描画する際に働く仕組みです。サーバー側でHTMLを生成する SSR のレンダリング中に投げられたエラーは、この境界では捕捉されません。SSR 環境では、フレームワーク側のエラーハンドリング(後述の Next.js の仕組みなど)に任せることになります。

Error Boundary 自身のエラー

境界が捕捉するのは、あくまで子孫で発生したエラーです。Error Boundary コンポーネント自身の render やフォールバックUIの中で例外が投げられた場合、その境界では拾えず、さらに外側の Error Boundary へ伝播します。フォールバックUIはできるだけ単純に保ち、そこで新たなエラーを起こさないようにしておくのが安全です。

実務ではライブラリやフレームワークの仕組みも使う

仕組みを理解するには自作が一番ですが、実務では毎回クラスを書かずに済ませることも多いです。よく使われるのが react-error-boundary ライブラリで、関数コンポーネントのように扱える <ErrorBoundary> と、リセットや再試行のためのフックがまとまっています。内部ではやはりクラスコンポーネントが使われていますが、利用側はその存在を意識せずに済みます。

また Next.js(App Router)では、ルートのセグメントごとに error.tsx を置くだけで、そのセグメント内のレンダリングエラーを自動でキャッチしフォールバックを表示できます。これは Next.js が内部で Error Boundary をセットアップしてくれる仕組みで、ファイルを用意するだけで境界が張られます。ただし本記事の主題はあくまで React 一般の Error Boundary を自作することなので、Next.js 固有の error.tsx の詳細は別の話題として扱います。まずはここで見たクラス実装が、こうしたライブラリやフレームワークの土台になっていることを押さえておくと理解が深まります。

まとめ

Error Boundary は、子コンポーネントのレンダリング中に投げられた例外をキャッチし、アプリ全体が真っ白になるのを防いでフォールバックUIに差し替える仕組みです。現状はクラスコンポーネントでしか作れず、static getDerivedStateFromError でフォールバック用の状態に切り替え、componentDidCatch でエラーの記録などの副作用を行います。壊れても他に波及させたくない単位で部分的に囲むのが基本で、state を初期化する reset を用意すれば再試行の導線も作れます。一方で、イベントハンドラ内・非同期処理・SSR・境界自身のエラーは捕捉できないため、それぞれ try...catch や状態管理で個別に対処します。実務では react-error-boundary や Next.js の error.tsx も使われますが、その土台にあるのは今回書いたクラス実装です。まずは自作して仕組みを掴んでおきましょう。

参考ページ