ReactのError Boundaryでエラーを受け止める方法

​​React​react-error-boundary,TypeScript,useTransition

React のコンポーネントが描画中にエラーを投げると、React は画面をすべて消します。エラーが起きうる部分を Error Boundary で包んでおけば、壊れた部分だけを「表示できませんでした」のような代わりの表示に差し替え、ほかの部分はそのまま残せます。

筆者は検証環境で、自分で書いた Error Boundary と react-error-boundary を、12通りの場面で動かしました。どこで投げたエラーなら受け止められるか、イベントハンドラーで起きたエラーをどう渡すか、受け止めたあとでどう表示し直すかを確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、react-error-boundary 6.1.6、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、jsdom の上で動かしました。React は開発用のビルドです。GIF だけは、Playwright 1.56.1 で Chromium 141 を動かして撮りました。

Error Boundary がないと、画面がすべて消える

まず、Error Boundary を置かずに試しました。見出しの Header と、描画中にエラーを投げる Profile を並べただけの画面です。

tsx
export function Header() {
  return <h1>マイページ</h1>;
}

export function Profile() {
  if (flags.profileBroken) {
    throw new Error('プロフィールを表示できません');
  }
  return <p>プロフィール: ねこ</p>;
}

export function NoBoundary() {
  return (
    <>
      <Header />
      <Profile />
    </>
  );
}

コードは抜粋で、import と flags の宣言は省いています。flags.profileBroken は、検証のために外から切り替えられるようにした値です。true にして描画すると、次のようになりました。

text
--- 1. Error Boundary なし ---
  [onUncaughtError] プロフィールを表示できません
  画面: (空)

エラーを投げたのは Profile だけですが、何の問題もない Header も一緒に消え、画面は空になりました。Component のリファレンスにある、Error Boundary の説明にも、描画中にエラーが投げられると React は UI を画面から取り除く、と書かれています。

[onUncaughtError] の行は、createRoot の2つ目の引数に渡した onUncaughtError が呼ばれたことを示しています。createRoot のリファレンスの、引数の説明のとおり、Error Boundary に受け止められなかったエラーで呼ばれる関数です。

Error Boundary はクラスコンポーネントで書く

Error Boundary は、static getDerivedStateFromError を持つクラスコンポーネントです。子のどこかが描画中にエラーを投げると、React はこのメソッドを呼びます。このメソッドが返した state をもとに、次の描画で代わりの表示に切り替わります。エラーの記録のような副作用は、componentDidCatch に書きます(getDerivedStateFromError のリファレンス)。

関数コンポーネントで同じことをする方法は、まだありません(Component のリファレンスの、関数コンポーネントについての注記)。そのため、クラスで1つ作って使い回すか、後で触れる react-error-boundary を使います。

筆者が検証に使ったのは、次の ErrorBoundary です。

tsx
import { Component, type ErrorInfo, type ReactNode } from 'react';

type FallbackProps = { error: unknown; reset: () => void };

type Props = {
  children: ReactNode;
  fallback: (props: FallbackProps) => ReactNode;
  onError?: (error: unknown, info: ErrorInfo) => void;
};

// error には null や文字列が入ることもあるので、エラーの有無は別に持つ
type State = { hasError: boolean; error: unknown };

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

  // 子のどこかで描画中にエラーが投げられたら、次の描画で fallback を出す
  static getDerivedStateFromError(error: unknown): State {
    return { hasError: true, error };
  }

  // エラーの記録など、副作用はこちらで行う
  componentDidCatch(error: unknown, info: ErrorInfo) {
    this.props.onError?.(error, info);
  }

  reset = () => {
    this.setState({ hasError: false, error: null });
  };

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

error の型を unknown にしたのは、JavaScript では Error 以外の値も投げられるからです。getDerivedStateFromError のリファレンスにも、引数には文字列や null が来る場合があると書かれています。null が来ても区別できるよう、エラーがあるかどうかは hasError に分けて持ちました。

fallback には、エラーと、表示し直すための reset を受け取る関数を渡します。検証では、エラーの文言と「もう一度試す」ボタンを出す Fallback を渡しました。

tsx
function Fallback({ error, reset }: { error: unknown; reset: () => void }) {
  return (
    <div role="alert">
      <p>表示できませんでした({error instanceof Error ? error.message : String(error)})</p>
      <button id="retry" onClick={reset}>もう一度試す</button>
    </div>
  );
}

export function WithBoundary({ log }: { log: (s: string) => void }) {
  return (
    <>
      <Header />
      <ErrorBoundary
        fallback={(p) => <Fallback {...p} />}
        onError={(error, info) => {
          log(`componentDidCatch: ${(error as Error).message}`);
          log(`componentStack:${info.componentStack}`);
        }}
      >
        <Profile />
      </ErrorBoundary>
    </>
  );
}

このコードも抜粋で、import は省いています。Profile だけを包んで描画した結果は、次のとおりです。

text
--- 2. Error Boundary あり ---
  [onCaughtError] プロフィールを表示できません
  [componentDidCatch] プロフィールを表示できません
  [componentStack]
    at Profile
    at ErrorBoundary
    at WithBoundary
  画面: <h1>マイページ</h1><div role="alert"><p>表示できませんでした(プロフィールを表示できません)</p><button id="retry">もう一度試す</button></div>

Header は残り、Profile の場所だけが代わりの表示に変わりました。componentDidCatch のリファレンスにあるとおり、componentDidCatch が受け取る info.componentStack には、エラーを投げた Profile から上へ、親のコンポーネントの名前が並びます(出力では、ファイルの場所を省いています)。createRoot に渡した onCaughtError も呼ばれました。こちらは、Error Boundary が受け止めたエラーで呼ばれる関数です。

包んだ部分だけが、代わりの表示に変わる

ブラウザでも、包んだ部分だけが差し替わることを確かめました。画面の上側にある「いいね」の枠だけを ErrorBoundary で包み、下側の「メモ」の枠は外に置いています。「いいね」の数を表示するコンポーネントは、数が3になると描画中にエラーを投げるようにしました。

+1を3回押すと、Error Boundaryの中の「いいね」の枠だけが「表示できませんでした」に変わり、外の「メモ」の枠はそのまま残る。「もう一度試す」を押すと、いいねは0から表示し直される
+1を3回押して「いいね」の枠だけが差し替わり、「もう一度試す」で0から表示し直されるまでの動き

3回目のクリックで「いいね」の枠だけが「表示できませんでした」に変わり、「メモ」の枠はそのまま残りました。「もう一度試す」を押すと、いいねは 0 から表示し直されます。代わりの表示に切り替わった時点で、中のコンポーネントはツリーから取り除かれています。state の保持とリセットのページの、state はツリーの位置に結び付く、という説明のとおり、React がコンポーネントを取り除くと、その state も消えます。そのため、reset で Error Boundary の state を戻すと中のコンポーネントは新しく作られ、それまでの state は残りません。

どこを包むかについて、Component のリファレンスの、Error Boundary の説明は、コンポーネントを1つずつ包む必要はないとしています。エラーの文言を出すのが自然な場所に置く、という考え方です。たとえばメッセージアプリなら、会話の一覧や、メッセージ1件ずつを包むのは理にかなっているが、アバター画像1つずつまで包む意味はない、という例が挙がっています。

受け止められるエラーと、受け止められないエラー

公式の説明では、Error Boundary が受け止めるのは、描画の途中で投げられたエラーです。Component のリファレンスの注記には、受け止めないものとして、イベントハンドラー、サーバーでの描画、Error Boundary 自身が投げたエラー、setTimeout のような非同期のコードが挙がっています。例外として、useTransition の startTransition に渡した関数の中で投げたエラーは受け止めます。

それぞれを ErrorBoundary で包んで試した結果が、次の表です。これ以降のコードも、import の行があるもの以外は import を省いた抜粋です。表の各ケースでは、ErrorBoundary と Fallback で包む部分も省いています。

エラーを投げた場所 Error Boundary 画面
描画中(Profile) 受け止めた 代わりの表示
useEffect の中 受け止めた 代わりの表示
onClick の中 受け止めなかった そのまま
setTimeout の中 受け止めなかった そのまま
onClick で捕まえて、state の更新関数の中で投げ直す 受け止めた 代わりの表示
startTransition に渡した async 関数の中 受け止めた 代わりの表示

onClick で投げた場合の出力です。

text
--- 3. イベントハンドラーで投げる ---
  画面: <h1>マイページ</h1><button id="btn">送る</button>
  > #btn をクリック
  [window error] クリックで失敗
  画面: <h1>マイページ</h1><button id="btn">送る</button>

画面は変わらず、エラーは jsdom の window の error イベントに届きました。onCaughtError も onUncaughtError も呼ばれていません。setTimeout の場合も画面は変わらず、エラーは Node.js の uncaughtException として出ました。どちらも、上の注記で受け止めないものとして挙がっている場所です。

useEffect の中で投げたエラーは受け止められ、代わりの表示に変わりました。useEffect は注記の「受け止めないもの」に入っていませんが、エフェクトについての説明もありません。useEffect の中のエラーが受け止められたのは、検証で確かめた結果です。

注意

イベントハンドラーや setTimeout のエラーは、Error Boundary で包んでいても代わりの表示になりません。ボタンを押しても何も起きないように見えるので、次の方法で Error Boundary に渡すか、ハンドラーの中で自分でエラーを表示します。

イベントハンドラーのエラーを Error Boundary に渡す

イベントハンドラーで起きたエラーを代わりの表示にしたいときは、描画の流れにエラーを持ち込みます。検証環境で次の3つの方法を試し、どれも受け止められました。

1つ目は、ハンドラーで捕まえたエラーを、state の更新関数の中で投げ直す方法です。useState のリファレンスの、前の state をもとに更新する説明のとおり、更新関数は次の描画の途中で呼ばれます。そのため、そこで投げたエラーは Error Boundary に届きます。

tsx
function RethrowInSetState() {
  const [, setState] = useState(0);
  return (
    <button
      id="btn"
      onClick={() => {
        try {
          throw new Error('クリックで失敗');
        } catch (e) {
          setState(() => { throw e; });
        }
      }}
    >
      送る
    </button>
  );
}
text
--- 6. ハンドラーで捕まえて、state の更新関数の中で投げ直す ---
  画面: <h1>マイページ</h1><button id="btn">送る</button>
  > #btn をクリック
  [onCaughtError] クリックで失敗
  画面: <h1>マイページ</h1><div role="alert"><p>表示できませんでした(クリックで失敗)</p><button id="retry">もう一度試す</button></div>

2つ目は、処理を startTransition に渡す方法です。useTransition のリファレンスの、Error Boundary でエラーを表示する例のとおり、渡した関数が投げたエラーや、返した Promise の失敗は、Error Boundary に届きます。検証では、await のあとで投げたエラーも受け止められました。

tsx
function ThrowInTransition() {
  const [isPending, startTransition] = useTransition();
  return (
    <button
      id="btn"
      disabled={isPending}
      onClick={() => {
        startTransition(async () => {
          await new Promise((r) => setTimeout(r, 10));
          throw new Error('送信に失敗');
        });
      }}
    >
      送る
    </button>
  );
}
text
--- 7. startTransition(async)の中で投げる ---
  画面: <h1>マイページ</h1><button id="btn">送る</button>
  > #btn をクリック
  [onCaughtError] 送信に失敗
  画面: <h1>マイページ</h1><div role="alert"><p>表示できませんでした(送信に失敗)</p><button id="retry">もう一度試す</button></div>

3つ目は、react-error-boundary の README の、イベントハンドラーと非同期のエラーの説明で勧められている、useErrorBoundary を使う方法です。返ってくる showBoundary にエラーを渡すと、いちばん近い Error Boundary が代わりの表示に切り替わります。この方法は、react-error-boundary の ErrorBoundary で包んだときにだけ使えます。

tsx
import { ErrorBoundary as LibBoundary, useErrorBoundary } from 'react-error-boundary';

function ShowBoundaryButton() {
  const { showBoundary } = useErrorBoundary();
  return (
    <button
      id="btn"
      onClick={() => {
        fetchLike().catch((e) => showBoundary(e));
      }}
    >
      送る
    </button>
  );
}

export function LibShowBoundary() {
  return (
    <>
      <Header />
      <LibBoundary
        fallbackRender={({ error, resetErrorBoundary }) => (
          <Fallback error={error} reset={resetErrorBoundary} />
        )}
      >
        <ShowBoundaryButton />
      </LibBoundary>
    </>
  );
}

fetchLike は、10ミリ秒待ってから「通信に失敗」を投げる、検証用の関数です。

text
--- 8. react-error-boundary の showBoundary ---
  画面: <h1>マイページ</h1><button id="btn">送る</button>
  > #btn をクリック
  [onCaughtError] 通信に失敗
  画面: <h1>マイページ</h1><div role="alert"><p>表示できませんでした(通信に失敗)</p><button id="retry">もう一度試す</button></div>

fallback の中で投げたエラーは、外側に届く

Error Boundary は、自分自身が投げたエラーを受け止めません。代わりの表示を描く途中でエラーが出たらどうなるかを、Error Boundary を二重にして確かめました。

tsx
export function BrokenFallback() {
  return (
    <ErrorBoundary fallback={() => <p>外側: 何かがうまくいきませんでした</p>}>
      <Header />
      <ErrorBoundary
        fallback={() => {
          throw new Error('fallback も失敗');
        }}
      >
        <Profile />
      </ErrorBoundary>
    </ErrorBoundary>
  );
}
text
--- 9. fallback の中で投げる ---
  [onCaughtError] fallback も失敗
  画面: <p>外側: 何かがうまくいきませんでした</p>

内側の代わりの表示が投げたエラーは、外側の Error Boundary が受け止めました。外側の Error Boundary の中にあった Header も、外側の代わりの表示に置き換わっています。アプリ全体を包む Error Boundary を1つ置いておくと、内側で受け止めきれなかったエラーで画面が空になるのを防げます。

エラーのあとで表示し直す

代わりの表示を出したままでは、ユーザーは先に進めません。表示し直す方法を2つ試しました。

reset で state を戻す

Fallback の「もう一度試す」は、ErrorBoundary の reset を呼び、hasError を false に戻します。原因が残ったまま押すと、もう一度同じエラーになり、代わりの表示のままでした。原因を直してから押すと、Profile が表示されました。次の出力は、さきほどの「2. Error Boundary あり」の続きです。

text
  (原因を直さずに「もう一度試す」)
  > #retry をクリック
  [onCaughtError] プロフィールを表示できません
  [componentDidCatch] プロフィールを表示できません
(中略)
  画面: <h1>マイページ</h1><div role="alert"><p>表示できませんでした(プロフィールを表示できません)</p><button id="retry">もう一度試す</button></div>
  (原因を直してから「もう一度試す」)
  > #retry をクリック
  画面: <h1>マイページ</h1><p>プロフィール: ねこ</p>

key や resetKeys で作り直す

表示するユーザーが変わったときのように、props が変われば直る場合もあります。ところが、ErrorBoundary は一度エラーを受け止めると、props が変わっても hasError を持ち続けます。broken というユーザーでエラーにしてから、表示するユーザーを neko に変えて試しました。

tsx
export function KeyReset({ userId }: { userId: string }) {
  return (
    <>
      <Header />
      <ErrorBoundary key={userId} fallback={(p) => <Fallback {...p} />}>
        <UserProfile userId={userId} />
      </ErrorBoundary>
    </>
  );
}
function UserProfile({ userId }: { userId: string }) {
  if (userId === 'broken') throw new Error(`${userId} は表示できません`);
  return <p>ユーザー: {userId}</p>;
}

key を付けない版(NoKeyReset)では、neko に変えても代わりの表示のままでした。

text
--- 10b. key を付けない場合(broken → neko) ---
  [onCaughtError] broken は表示できません
  画面: <h1>マイページ</h1><div role="alert"><p>表示できませんでした(broken は表示できません)</p><button id="retry">もう一度試す</button></div>
  > userId を neko に変える
  画面: <h1>マイページ</h1><div role="alert"><p>表示できませんでした(broken は表示できません)</p><button id="retry">もう一度試す</button></div>

ErrorBoundary に key={userId} を付けると、neko に変えたときに表示されました。

text
--- 10. key で作り直す(broken → neko) ---
  [onCaughtError] broken は表示できません
  画面: <h1>マイページ</h1><div role="alert"><p>表示できませんでした(broken は表示できません)</p><button id="retry">もう一度試す</button></div>
  > userId を neko に変える
  画面: <h1>マイページ</h1><p>ユーザー: neko</p>

key が変わると、React はそのコンポーネントを作り直し、state も初めからにします(state の保持とリセットのページにある、key でリセットする方法)。Error Boundary の hasError も、ほかの state と同じように消えます。

react-error-boundary の ErrorBoundary では、README の、省略できる props の説明のとおり、resetKeys に値の配列を渡すと、その値が変わったときに表示し直します。resetKeys={[userId]} にした版でも、neko に変えると表示されました。

text
--- 11. react-error-boundary の resetKeys(broken → neko) ---
  [onCaughtError] broken は表示できません
  画面: <h1>マイページ</h1><div role="alert"><p>表示できませんでした(broken は表示できません)</p><button id="retry">もう一度試す</button></div>
  > userId を neko に変える
  画面: <h1>マイページ</h1><p>ユーザー: neko</p>

エラーを記録する場所

受け止めたエラーを記録するには、Error Boundary の componentDidCatch か、createRoot に渡す onCaughtError を使います。createRoot のリファレンスの、本番でのエラーの記録では、onCaughtError・onUncaughtError・onRecoverableError の3つを使って、エラーを報告する仕組みを作る例が紹介されています。何も渡さないと、React はすべてのエラーをコンソールに出します。

なお、componentDidCatch のリファレンスの注意事項には、開発用のビルドでは受け止めたエラーも window まで伝わり、本番用のビルドでは伝わらない、と書かれています。ただし検証環境(React 19.3.1 の開発用ビルド、jsdom、onCaughtError を渡した状態)では、Error Boundary が受け止めたエラーで window の error イベントは起きませんでした。ブラウザと本番用のビルドでの動きは確かめていません。

ポイント

Error Boundary は、描画中・useEffect の中・startTransition の中のエラーを受け止めます。イベントハンドラーや setTimeout のエラーは受け止めないので、state の更新関数で投げ直すか、startTransition や showBoundary で渡します。エラーのあとで表示し直すには、reset で state を戻すか、key を変えて作り直します。

​Posted by 86