Error Boundary(エラーバウンダリ)とは、子コンポーネントの描画中に起きたエラーを受け止めて、画面全体が消える代わりに「フォールバック」と呼ぶ代わりの表示を出す仕組みです。 境界を置いていないReactアプリでは、描画中のエラー1つでルート全体の表示が空になります。境界を置くと、影響をその部品だけに留められます。

一方で、Error Boundaryはイベントハンドラや setTimeout、Promiseの中のエラーは受け止めません。この記事では、どのエラーが捕まり、どれが捕まらないのかを13のケースで実際に動かして表にまとめ、捕まらないものを境界へ渡す方法(showBoundary、startTransition、use()とSuspense)を再現コードつきで示します。クラスでの実装とreact-error-boundaryの書き方をTypeScriptで並べ、再試行、React 19のonCaughtError / onUncaughtError、境界の置き場所、ログ送信まで扱います。

確認環境:Ubuntu 24.04、Node.js 22.22、React 19.3.0 / react-dom 19.3.0、react-error-boundary 6.1.6、TypeScript 7.0.2。コードはesbuild 0.28.2でまとめ、Playwright 1.56.0のChromium 141で動かしました。開発ビルドと本番ビルドの両方で同じ結果になることを確かめています。

Error Boundaryで捕まえられるエラー・捕まえられないエラーの早見表

「境界」は、エラーを起こすコンポーネントを囲んだ ErrorBoundary のことです。右の2列は今回の実行結果です。

エラーが起きる場所 境界が捕まえるか 画面 届いた先 対処
描画中(コンポーネント関数の本体) 捕まえる フォールバック onCaughtError 境界を置く
useEffectの中(同期部分) 捕まえる フォールバック onCaughtError 境界を置く
refコールバック 捕まえる フォールバック onCaughtError 境界を置く
イベントハンドラ(onClickなど) 捕まえない 変わらない windowのerrorイベント try/catchして showBoundary
setTimeoutのコールバック 捕まえない 変わらない windowのerrorイベント try/catchして showBoundary
Promise(awaitのあと) 捕まえない 変わらない windowのunhandledrejectionイベント showBoundary、startTransition、use()
useTransition のstartTransitionに渡した関数 捕まえる フォールバック onCaughtError そのまま使える
use() で読んだPromiseのreject 捕まえる フォールバック onCaughtError Suspense と組み合わせる
境界自身(フォールバックの描画) その境界では捕まえない 外側の境界のフォールバック。外側が無ければ空 外側のonCaughtError、またはonUncaughtError 外側にも境界を置く。フォールバックは単純にする
サーバー側の描画(renderToString) 捕まえない renderToStringが例外を投げる 呼び出し元のtry/catch サーバー側で受ける
境界が無い場所の描画エラー 捕まえるものが無い ルート全体が空 onUncaughtError いちばん外側にも境界を置く

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React Error Boundaryの使い方|非同期エラー対応」

まず動かす:react-error-boundaryで最小の境界を置く

Reactの公式ドキュメントには、Error Boundaryを関数コンポーネントとして書く方法は今のところ無く、クラスを自分で書く代わりにreact-error-boundaryパッケージを使えると書かれています。まずはこのパッケージで動かします。

npm install react react-dom react-error-boundary
npm install -D typescript @types/react @types/react-dom

フォールバックのコンポーネントを1つ用意します。受け取るpropsは error(投げられた値)と resetErrorBoundary(境界を元に戻す関数)です。

// src/Fallback.tsx
import { getErrorMessage, type FallbackProps } from "react-error-boundary";

export function Fallback({ error, resetErrorBoundary }: FallbackProps) {
  return (
    <div role="alert">
      FALLBACK: {getErrorMessage(error)}{" "}
      <button id="retry" onClick={() => resetErrorBoundary()}>
        retry
      </button>
    </div>
  );
}

エラーを起こすかもしれない部品を ErrorBoundary で囲みます。フォールバックの渡し方は3通りあります。

props 渡すもの 向いている場面
fallback 表示する要素そのもの エラーの中身を使わない固定の表示
fallbackRender { error, resetErrorBoundary } を受け取って要素を返す関数 その場で短く書きたいとき
FallbackComponent 上の2つをpropsで受け取るコンポーネント 共通のフォールバックを使い回すとき

そのほかのpropsは次のとおりです。

props 役割
onError(error, info) 境界がエラーを捕まえたときに呼ばれる。ログ送信に使う
onReset(details) 境界が元に戻るときに呼ばれる。キャッシュの破棄などに使う
resetKeys 配列の中身が変わったら、境界を自動で元に戻す

errorの型はunknown:TypeScriptでのつまずき

react-error-boundary 6では、FallbackProps の error の型は unknown です。JavaScriptでは throw でどんな値でも投げられるためです。次のように error.message を直接読むと、型チェックで止まります。

// bad/Fallback.tsx
import type { FallbackProps } from "react-error-boundary";

export function Fallback({ error, resetErrorBoundary }: FallbackProps) {
  return (
    <div role="alert">
      <p>{error.message}</p>
      <button onClick={() => resetErrorBoundary()}>再試行</button>
    </div>
  );
}
$ npx tsc --noEmit -p tsconfig.bad.json
bad/Fallback.tsx(6,11): error TS18046: 'error' is of type 'unknown'.

パッケージが用意している getErrorMessage(error) を使うか、error instanceof Error で確かめてから読みます。上の src/Fallback.tsx は getErrorMessage を使った形で、tsc --noEmit を通ります。

クラスで書くError Boundary(TypeScript)

パッケージを使わずに書く場合は、クラスコンポーネントに2つのメソッドを実装します。

// src/ClassBoundary.tsx
import { Component, type ErrorInfo, type ReactNode } from "react";

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

type State = { hasError: boolean; error: unknown };

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

  // 描画の途中で呼ばれる。次の state を返すだけにして、副作用は書かない
  static getDerivedStateFromError(error: unknown): State {
    return { hasError: true, error };
  }

  // 画面への反映後に呼ばれる。ログ送信などの副作用はここに書く
  componentDidCatch(error: unknown, info: ErrorInfo): void {
    this.props.onError?.(error, info);
  }

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

  render(): ReactNode {
    if (this.state.hasError) {
      return this.props.fallback(this.state.error, this.reset);
    }
    return this.props.children;
  }
}
メソッド 呼ばれるとき 書くこと
static getDerivedStateFromError(error) 子がエラーを投げたとき、描画の途中 フォールバックを出すためのstateを返す。副作用は書かない
componentDidCatch(error, info) 画面に反映したあと ログ送信などの副作用。info.componentStack にコンポーネントの階層が入る

公式ドキュメントでも、getDerivedStateFromError は純粋な関数にし、副作用が必要なら componentDidCatch も実装するよう書かれています。

クラス実装とreact-error-boundaryの対応

やりたいこと クラス実装 react-error-boundary
フォールバックを出す getDerivedStateFromError と render の分岐 fallback / fallbackRender / FallbackComponent
ログを送る componentDidCatch onError
再試行する setState でエラー状態を消すメソッドを自作 resetErrorBoundary
値が変わったら自動で戻す componentDidUpdate で比較を自作 resetKeys
イベントハンドラのエラーを渡す 仕組みを自作 useErrorBoundary().showBoundary

仕組みを理解するにはクラス実装が役立ち、日々の開発では再試行まわりが揃っているreact-error-boundaryが手軽です。

再現コード:13のケースを実際に動かす

エラーを起こす部品

それぞれ違う場所でエラーを投げる部品を用意しました。

// src/bombs.tsx
import { useEffect, type ReactNode } from "react";

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

// 1. 描画中に投げる
export function RenderBomb(): ReactNode {
  throw new Error("render error");
}

// 2. useEffect の中で投げる
export function EffectBomb() {
  useEffect(() => {
    throw new Error("effect error");
  }, []);
  return <p>effect ok</p>;
}

// 3. イベントハンドラの中で投げる
export function ClickBomb() {
  const onClick = () => {
    throw new Error("click error");
  };
  return <button id="b" onClick={onClick}>click</button>;
}

// 4. setTimeout のコールバックで投げる
export function TimerBomb() {
  useEffect(() => {
    const t = setTimeout(() => {
      throw new Error("timer error");
    }, 10);
    return () => clearTimeout(t);
  }, []);
  return <p>timer ok</p>;
}

// 5. await のあとで投げる(Promise が reject される)
export function PromiseBomb() {
  useEffect(() => {
    (async () => {
      await sleep(10);
      throw new Error("promise error");
    })();
  }, []);
  return <p>promise ok</p>;
}

// 6. フォールバックの描画そのものが投げる
export function BadFallback(): ReactNode {
  throw new Error("fallback error");
}

// 7. ref コールバックで投げる
export function RefBomb() {
  return (
    <p
      ref={() => {
        throw new Error("ref error");
      }}
    >
      ref
    </p>
  );
}

対処を入れた部品

// src/fixes.tsx
import { use, useTransition } from "react";
import { useErrorBoundary } from "react-error-boundary";

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

// 対処A:try/catch で受けて showBoundary で境界へ渡す
export function ShowBoundaryClick() {
  const { showBoundary } = useErrorBoundary();
  const onClick = async () => {
    try {
      await sleep(10);
      throw new Error("fetch error");
    } catch (e) {
      showBoundary(e);
    }
  };
  return <button id="b" onClick={onClick}>load</button>;
}

// 対処B:useTransition の startTransition に渡した関数の中で投げる
export function TransitionSave() {
  const [isPending, startTransition] = useTransition();
  const onClick = () => {
    startTransition(async () => {
      await sleep(10);
      throw new Error("transition error");
    });
  };
  return (
    <button id="b" onClick={onClick} disabled={isPending}>
      save
    </button>
  );
}

// 対処C:Promise を use() で読む(Suspense と組み合わせる)
export function UseReader({ promise }: { promise: Promise<string> }) {
  const value = use(promise);
  return <p>{value}</p>;
}

確認に使ったコード

各ケースを別々のルートに描画し、画面の文字列と、どのコールバックやイベントに届いたかを記録します。createRoot の onCaughtError と onUncaughtError はReact 19で加わったオプションで、あとの節で説明します。

// src/verify.tsx
import { Suspense, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { ErrorBoundary, getErrorMessage } from "react-error-boundary";
import { ClassBoundary } from "./ClassBoundary";
import { Fallback } from "./Fallback";
import { BadFallback, ClickBomb, EffectBomb, PromiseBomb, RefBomb, RenderBomb, TimerBomb } from "./bombs";
import { ShowBoundaryClick, TransitionSave, UseReader } from "./fixes";
import { BoundaryPerWidget, OneBoundaryAtTop } from "./layouts";
import { RetryDemo } from "./RetryDemo";
import { ResetKeysDemo } from "./ResetKeysDemo";

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const results: Record<string, unknown> = {};
let log: string[] = [];

// 境界の外へ出たエラーは window のイベントに届く
window.addEventListener("error", (e) => {
  log.push("window.error: " + e.message);
  e.preventDefault();
});
window.addEventListener("unhandledrejection", (e) => {
  log.push("unhandledrejection: " + getErrorMessage(e.reason));
  e.preventDefault();
});

function mount(ui: ReactNode) {
  log = [];
  const el = document.createElement("div");
  document.body.appendChild(el);
  const root = createRoot(el, {
    onCaughtError: (error) => log.push("onCaughtError: " + getErrorMessage(error)),
    onUncaughtError: (error) => log.push("onUncaughtError: " + getErrorMessage(error)),
  });
  root.render(ui);
  const click = (selector: string) => el.querySelector<HTMLButtonElement>(selector)?.click();
  const done = () => {
    root.unmount();
    el.remove();
  };
  return { el, click, done };
}

async function runCase(name: string, ui: ReactNode, clickSelector?: string) {
  const m = mount(ui);
  await sleep(50);
  if (clickSelector) m.click(clickSelector);
  await sleep(600);
  results[name] = { text: m.el.textContent, log };
  m.done();
}

const eb = (child: ReactNode) => <ErrorBoundary FallbackComponent={Fallback}>{child}</ErrorBoundary>;

(layouts、RetryDemo、ResetKeysDemo は、あとの節に載せています。このあとに続くmain関数では、各ケースの要素を順に runCase へ渡し、最後に結果を window.__results に入れています)

これをesbuildで1ファイルにまとめ(開発ビルドと本番ビルドの2通り)、PlaywrightからChromiumで読み込んで window.__results を取り出しました。npx tsc --noEmit -p . はエラーなしで通り、2つのビルドの出力は一致しました。

実行結果:捕まえられるエラー

"01 描画中(クラス実装)": { "text": "CLASS FALLBACK: render error",   "log": ["onCaughtError: render error"] }
"02 描画中":               { "text": "FALLBACK: render error retry",   "log": ["onCaughtError: render error"] }
"03 useEffect":            { "text": "FALLBACK: effect error retry",   "log": ["onCaughtError: effect error"] }
"09 ref コールバック":     { "text": "FALLBACK: ref error retry",      "log": ["onCaughtError: ref error", "onCaughtError: ref error"] }

(1ケース1行に整形しています。値は出力のままです)

描画中、useEffectの同期部分、refコールバックのエラーは境界が捕まえ、フォールバックに切り替わりました。 共通点は、Reactが自分で呼び出している処理の中で起きている、という点です。refコールバックのケースでは、onCaughtErrorが2回呼ばれていました。

実行結果:捕まえられないエラー

"04 イベントハンドラ": { "text": "click",      "log": ["window.error: Uncaught Error: click error"] }
"05 setTimeout":       { "text": "timer ok",   "log": ["window.error: Uncaught Error: timer error"] }
"06 Promise":          { "text": "promise ok", "log": ["unhandledrejection: promise error"] }

3つとも、画面は元のまま変わらず、フォールバックは出ていません。 エラーは境界にもonCaughtErrorにも届かず、window の error イベント(Promiseは unhandledrejection イベント)に届いています。

理由は、エラーが起きた時点でReactの描画がすでに終わっているためです。イベントハンドラはクリックされたときに、setTimeout のコールバックやawaitの続きはあとから呼ばれます。描画の流れの外で起きたエラーなので、「どの境界の内側か」をReactが決められません。公式ドキュメントでも、境界が捕まえないものとして、イベントハンドラ、サーバー側の描画、境界自身が投げたエラー、非同期のコード(setTimeout や requestAnimationFrame のコールバック)が挙げられています。

境界自身が投げた場合

フォールバックの描画がエラーを投げるケースです。

"07 フォールバックが投げる(外側の境界なし)": { "text": "",               "log": ["onUncaughtError: fallback error"] }
"08 フォールバックが投げる(外側の境界あり)": { "text": "OUTER FALLBACK", "log": ["onCaughtError: fallback error"] }
"10 境界なし":                                 { "text": "",               "log": ["onUncaughtError: render error"] }

ケース07と08で描画した要素は次のとおりです(src/verify.tsx のmain関数からの抜き出し)。

// 07:外側の境界なし
<ErrorBoundary fallbackRender={() => <BadFallback />}>
  <RenderBomb />
</ErrorBoundary>

// 08:外側の境界あり
<ErrorBoundary fallback={<p>OUTER FALLBACK</p>}>
  <ErrorBoundary fallbackRender={() => <BadFallback />}>
    <RenderBomb />
  </ErrorBoundary>
</ErrorBoundary>

境界は、自分のフォールバックが投げたエラーを自分では受け止めません。 エラーは1つ外側の境界へ進みます(ケース08)。外側に境界が無いと、境界が1つも無いケース10と同じく、そのルートの表示がすべて消えました(textが空文字)。

ここからわかる書き方は2つです。

  • フォールバックは、データ取得や複雑な計算をしない単純な表示にする
  • アプリのいちばん外側にも、固定の表示だけを出す境界を1つ置く

サーバー側の描画

Node.js上で renderToString を呼んで確かめました。

// src/ssr.tsx
import { type ReactNode } from "react";
import { renderToString } from "react-dom/server";
import { ErrorBoundary } from "react-error-boundary";

function Ok() {
  return <p>ok</p>;
}

function Bomb(): ReactNode {
  throw new Error("render error on server");
}

console.log("1:", renderToString(<ErrorBoundary fallback={<p>fallback</p>}><Ok /></ErrorBoundary>));

try {
  const html = renderToString(<ErrorBoundary fallback={<p>fallback</p>}><Bomb /></ErrorBoundary>);
  console.log("2:", html);
} catch (e) {
  console.log("2: renderToString が例外を投げた ->", (e as Error).message);
}
npx esbuild src/ssr.tsx --bundle --platform=node --jsx=automatic --define:process.env.NODE_ENV='"production"' --outfile=ssr.cjs
node ssr.cjs
1: <p>ok</p>
2: renderToString が例外を投げた -> render error on server

境界で囲んでいても、フォールバックのHTMLにはならず、renderToString 自体が例外を投げました。サーバー側では、呼び出し元の try/catch で受けて、エラー用のHTMLを返す処理を用意します。

捕まえられないエラーを境界へ渡す3つの方法

"11 showBoundary":     { "text": "FALLBACK: fetch error retry",      "log": ["onCaughtError: fetch error"] }
"12 startTransition":  { "text": "FALLBACK: transition error retry", "log": ["onCaughtError: transition error"] }
"13 use + Suspense":   { "text": "FALLBACK: use error retry",        "log": ["onCaughtError: use error"] }

3つとも、非同期の処理の中で起きたエラーが境界に届き、フォールバックが出ました。

方法A:try/catchして showBoundary で渡す

useErrorBoundary() が返す showBoundary(error) を呼ぶと、いちばん近い ErrorBoundary にエラーを渡せます。src/fixes.tsx の ShowBoundaryClick がこの形です。イベントハンドラ、setTimeout、Promiseのどれにも使えます。

直す前(境界に届かない)と直した後を並べます。

// 直す前(src/bombs.tsx の ClickBomb):画面は変わらず、エラーは window に届く
const onClick = () => {
  throw new Error("click error");
};

// 直した後(src/fixes.tsx の ShowBoundaryClick):catch して境界へ渡す
const { showBoundary } = useErrorBoundary();
const onClick = async () => {
  try {
    await sleep(10);
    throw new Error("fetch error");
  } catch (e) {
    showBoundary(e);
  }
};

(上は src/fixes.tsx と src/bombs.tsx から該当部分を抜き出したものです)

パッケージの型定義のコメントにあるとおり、useErrorBoundary は ErrorBoundary の内側のコンポーネントで呼びます。

なお、すべてのイベントハンドラのエラーを境界へ渡す必要はありません。 保存ボタンを押してエラーになったときは、入力内容を残したままボタンの近くにメッセージを出すほうが親切です。フォールバックに切り替えると、囲んだ範囲の表示がまるごと置き換わります。「この部品を表示し続けられない」ときだけ showBoundary を使います。

方法B:useTransition の startTransition に渡す

React 19では、useTransition が返す startTransition に渡した関数(非同期関数も可)の中で投げたエラーは、境界が捕まえます。公式ドキュメントにも、非同期コードは捕まえないという説明の例外として書かれています。src/fixes.tsx の TransitionSave がこの形で、try/catch を書かずに済みます。処理中は isPending がtrueになるので、ボタンを押せなくする表示にも使えます。

方法C:Promiseを use() で読み、Suspenseと組み合わせる

use(promise) でPromiseを読むコンポーネントは、Promiseがrejectされると、そのエラーを描画中のエラーとして投げます。そのため境界が捕まえます。読み込み中の表示は Suspense、エラーの表示は ErrorBoundary と役割を分けられます。

<ErrorBoundary FallbackComponent={Fallback}>
  <Suspense fallback={<p>loading</p>}>
    <UseReader promise={rejected} />
  </Suspense>
</ErrorBoundary>

(src/verify.tsx のケース13と同じ構造です)

ErrorBoundary を Suspense の外側に置くと、読み込み中の表示とエラーの表示の両方を、同じ場所に出せます。

3つの方法の選び方

状況 選ぶ方法
ボタン操作のあとのエラーで、部品ごとフォールバックに切り替えたい A:showBoundary
setTimeout や外部ライブラリのコールバックの中 A:showBoundary
フォームの送信や保存など、処理中の表示も出したい B:startTransition
表示に必要なデータの読み込み C:use() と Suspense
入力内容を残してメッセージだけ出したい 境界へ渡さず、stateでメッセージを出す

再試行:resetErrorBoundary、onReset、resetKeys

フォールバックを出したあと、元の表示に戻す方法は2つあります。

ボタンで再試行する(resetErrorBoundary と onReset)

フォールバックの中で resetErrorBoundary() を呼ぶと、境界がエラー状態を消して、子をもう一度描画します。そのとき onReset が呼ばれます。

// src/RetryDemo.tsx
import { ErrorBoundary } from "react-error-boundary";
import { Fallback } from "./Fallback";

// 再試行の回数。2回目の再試行で読み込めるようになる想定
let resets = 0;

function Flaky() {
  if (resets < 2) {
    throw new Error("load failed (resets=" + resets + ")");
  }
  return <p>loaded (resets={resets})</p>;
}

export function RetryDemo({ log }: { log: (line: string) => void }) {
  return (
    <ErrorBoundary
      FallbackComponent={Fallback}
      onError={(error, info) => {
        const inStack = info.componentStack?.includes("Flaky");
        log("onError: " + String(error) + " / componentStack に Flaky: " + inStack);
      }}
      onReset={(details) => {
        resets++; // ここでキャッシュの破棄や state の初期化を行う
        log("onReset: " + JSON.stringify(details));
      }}
    >
      <Flaky />
    </ErrorBoundary>
  );
}

retryボタンを2回押したときの記録です。

onError: Error: load failed (resets=0) / componentStack に Flaky: true
画面: FALLBACK: load failed (resets=0) retry
onReset: {"args":[],"reason":"imperative-api"}
onError: Error: load failed (resets=1) / componentStack に Flaky: true
画面: FALLBACK: load failed (resets=1) retry
onReset: {"args":[],"reason":"imperative-api"}
画面: loaded (resets=2)

1回目の再試行ではまだエラーになり、もう一度フォールバックが出ます。2回目で読み込めて、通常の表示に戻りました。onReset の引数の reason は、ボタンなどから呼んだ場合 "imperative-api" です。

エラーの原因が残ったまま再描画すると、同じエラーがもう一度起きます。 onReset の中で、エラーの元になったキャッシュを捨てる、stateを初期値に戻す、といった処理を行います。

値が変わったら自動で戻す(resetKeys)

resetKeys に渡した配列の中身が変わると、境界は自動で元に戻ります。「表示する対象が変わったら、前の対象のエラー表示を消したい」ときに使います。

// src/ResetKeysDemo.tsx
import { useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Fallback } from "./Fallback";

function Member({ id }: { id: number }) {
  if (id === 2) {
    throw new Error("member 2 is broken");
  }
  return <p>member {id}</p>;
}

export function ResetKeysDemo({ log }: { log: (line: string) => void }) {
  const [id, setId] = useState(1);
  return (
    <div>
      <button id="next" onClick={() => setId((n) => n + 1)}>
        next
      </button>
      <ErrorBoundary
        FallbackComponent={Fallback}
        resetKeys={[id]}
        onReset={(details) => log("onReset: " + JSON.stringify(details))}
      >
        <Member id={id} />
      </ErrorBoundary>
    </div>
  );
}

nextボタンでidを1→2→3と進めたときの記録です。

id=1 画面: nextmember 1
id=2 画面: nextFALLBACK: member 2 is broken retry
onReset: {"next":[3],"prev":[2],"reason":"keys"}
id=3 画面: nextmember 3

id=2でフォールバックが出て、id=3に進むと再試行ボタンを押さなくても通常の表示に戻りました。このとき onReset の reason は "keys" で、変わる前と後の配列が prev と next に入ります。

戻し方 きっかけ onResetのreason 向いている場面
resetErrorBoundary() 利用者の操作(再試行ボタン) "imperative-api" 通信の一時的なエラーなど、同じ内容をもう一度試す
resetKeys 配列の値の変化 "keys" 表示対象やURLが変わったら、エラー表示を消す

React 19のonCaughtErrorとonUncaughtError

React 19では、createRoot のオプションで、エラーの通知をルート単位で受け取れます。公式ドキュメントでの説明は次のとおりです。

オプション 呼ばれるとき 引数
onCaughtError Error Boundaryがエラーを捕まえたとき error と、componentStack を持つ errorInfo
onUncaughtError エラーが投げられ、どのError Boundaryにも捕まえられなかったとき 同上
onRecoverableError Reactがエラーから自動で回復したとき 同上

今回の13ケースでも、境界が捕まえたものはonCaughtErrorに、境界が無い・境界自身が投げたものはonUncaughtErrorに届きました。個々の境界に onError を書かなくても、ルートの1か所ですべての境界のエラーを受け取れるのが利点です。

オプションを渡さないときの既定の動き

公式ドキュメントには、既定ではReactがすべてのエラーをコンソールに出力すると書かれています。オプションなしの createRoot で、描画中にエラーを投げる部品を動かして確かめました(開発ビルド・本番ビルドとも同じ)。

状況 コンソール windowのerrorイベント 画面
境界あり console.error で Error: render error とスタックが出る 届かない フォールバック
境界なし 未捕捉のエラーとして出る 届く(Uncaught Error: render error) 空

開発ビルドでは、境界なしのときに次の案内も出ました。

An error occurred in the <Bomb> component. Consider adding an error boundary to your tree to customize error handling behavior.

境界が捕まえたエラーは、windowのerrorイベントには届きません。 windowのイベントだけでエラーを集めていると、フォールバックを出したエラーが記録から抜けます。次の節のように、onCaughtErrorでも送るようにします。

ログ送信:どこで何を送るか

エラーを記録する場所は3つあります。

場所 拾えるエラー 書く場所
createRoot の onCaughtError すべての境界が捕まえたエラー アプリの入口に1回
createRoot の onUncaughtError どの境界にも捕まえられなかった描画エラー アプリの入口に1回
window の error / unhandledrejection イベントハンドラ、setTimeout、Promiseのエラー アプリの入口に1回

送信する関数を1つにまとめます。

// src/report.ts
import { getErrorMessage } from "react-error-boundary";

type Kind = "caught" | "uncaught" | "recoverable" | "window";

// エラー1件ぶんの情報をまとめて、サーバーへ送る
export function report(kind: Kind, error: unknown, componentStack?: string | null): void {
  const payload = {
    kind,
    message: getErrorMessage(error) ?? String(error),
    // コンポーネントの階層。長くなるので先頭の数行に絞る
    componentStack: componentStack?.trim().split("\n").slice(0, 4).map((line) => line.trim()),
    url: location.href,
  };
  // sendBeacon は画面を離れる直前でも送信を任せられる
  navigator.sendBeacon("/api/client-errors", JSON.stringify(payload));
}

アプリの入口で、3か所すべてから同じ関数を呼びます。

// src/main.tsx
import { type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { ErrorBoundary } from "react-error-boundary";
import { Fallback } from "./Fallback";
import { report } from "./report";

function OrderList(): ReactNode {
  throw new Error("orders is undefined");
}

function OrderPage() {
  return (
    <main>
      <OrderList />
    </main>
  );
}

function ExportButton() {
  const onClick = () => {
    throw new Error("export failed");
  };
  return <button id="export" onClick={onClick}>export</button>;
}

function App() {
  return (
    <div>
      <ExportButton />
      <ErrorBoundary FallbackComponent={Fallback}>
        <OrderPage />
      </ErrorBoundary>
    </div>
  );
}

// 境界が捕まえないエラー(イベントハンドラなど)は window のイベントで拾う
window.addEventListener("error", (e) => report("window", e.error));
window.addEventListener("unhandledrejection", (e) => report("window", e.reason));

const root = createRoot(document.getElementById("root")!, {
  // 境界が捕まえたエラー
  onCaughtError: (error, info) => report("caught", error, info.componentStack),
  // どの境界にも捕まえられなかったエラー
  onUncaughtError: (error, info) => report("uncaught", error, info.componentStack),
  // React が自動で回復したエラー
  onRecoverableError: (error, info) => report("recoverable", error, info.componentStack),
});
root.render(<App />);

このページをChromiumで開き、exportボタンを1回押して、/api/client-errors に届いたリクエストを記録しました(本番ビルド)。

[
 {
  "method": "POST",
  "body": {
   "kind": "caught",
   "message": "orders is undefined",
   "componentStack": [
    "at OrderList (<anonymous>:14693:11)",
    "at main (<anonymous>)",
    "at OrderPage (<anonymous>)",
    "at m (<anonymous>:14584:15)"
   ],
   "url": "http://app.test/orders"
  }
 },
 {
  "method": "POST",
  "body": {
   "kind": "window",
   "message": "export failed",
   "url": "http://app.test/orders"
  }
 }
]
  • 1件目は、境界が捕まえた描画エラーです。componentStack から、OrderPageの中のOrderListで起きたと読み取れます。4行目の m は、圧縮された状態で配布されているreact-error-boundaryの ErrorBoundary クラスです。
  • 2件目は、イベントハンドラのエラーです。境界を通らないため、windowのイベント経由で届いています。componentStack はありません。

公式ドキュメントによると、本番ビルドではコンポーネント名が圧縮されるため、ソースマップを使って元の名前に戻します。送る内容に個人の情報(入力値やトークンなど)を含めないことも、あわせて確かめておきます。

境界の置き場所:どの粒度で囲むか

同じ画面(ヘッダー、一覧、エラーを起こす部品)を、境界の置き方を変えて描画しました。

// src/layouts.tsx
import { ErrorBoundary } from "react-error-boundary";
import { RenderBomb } from "./bombs";

const Header = () => <header>[header]</header>;
const List = () => <section>[list]</section>;
const widgetFallback = <p>[widget unavailable]</p>;

// 置き方1:いちばん外側に1つだけ
export function OneBoundaryAtTop() {
  return (
    <ErrorBoundary fallback={<p>[whole page fallback]</p>}>
      <Header />
      <List />
      <RenderBomb />
    </ErrorBoundary>
  );
}

// 置き方2:部品ごとに囲む
export function BoundaryPerWidget() {
  return (
    <div>
      <Header />
      <ErrorBoundary fallback={widgetFallback}>
        <List />
      </ErrorBoundary>
      <ErrorBoundary fallback={widgetFallback}>
        <RenderBomb />
      </ErrorBoundary>
    </div>
  );
}
"20 外側に1つ": { "text": "[whole page fallback]",              "log": ["onCaughtError: render error"] }
"21 部品ごと":  { "text": "[header][list][widget unavailable]", "log": ["onCaughtError: render error"] }

外側に1つだけ置くと、ヘッダーも一覧も消えて、ページ全体がフォールバックになります。部品ごとに囲むと、エラーを起こした部品だけがフォールバックになり、ヘッダーと一覧はそのまま使えます。

置く場所 フォールバックの内容 役割
アプリのいちばん外側 「表示できませんでした。再読み込みしてください」などの固定表示 最後の受け皿。ルート全体が空になるのを防ぐ
ページ(ルート)単位 ページ用のエラー表示。ナビゲーションは残す ほかのページへ移動できる状態を保つ
独立した部品単位(グラフ、コメント欄、おすすめ一覧など) 「この部分を表示できませんでした」と再試行ボタン ほかの部品を使い続けられる
一覧の1行、フォームの1項目 囲まないことが多い 細かすぎるとフォールバックだらけになり、かえって使いにくい

目安は、「その部品が表示できなくても、残りの画面に意味があるか」 です。意味があるなら囲み、無いなら1つ外側の境界に任せます。

つまずきやすい点と直し方

起きること 理由 こうするとよい
ボタンを押したときのエラーでフォールバックが出ない イベントハンドラは境界の対象外 try/catch して showBoundary、または startTransition
データ取得のエラーでフォールバックが出ない awaitのあとのエラーは境界の対象外 use() と Suspense、または showBoundary
再試行を押しても同じフォールバックに戻る エラーの原因が残っている onReset でキャッシュやstateを戻す
別のデータを選んでもエラー表示のまま 境界がエラー状態を持ち続けている resetKeys に対象のidを渡す
エラーで画面がすべて消える 境界が無い、またはフォールバックがエラーを投げた いちばん外側に固定表示の境界を置く
error.message で型エラー(TS18046) error の型は unknown getErrorMessage(error) か instanceof Error
フォールバックを出したエラーがログに無い windowのerrorイベントには届かない onCaughtError か onError で送る
サーバー側の描画でフォールバックにならない サーバー側の描画は境界の対象外 呼び出し元で try/catch する

よくある質問

Q. Error Boundaryは関数コンポーネントで書けますか? A. 公式ドキュメントには、今のところ関数コンポーネントとして書く方法は無いと書かれています。クラスを自分で書くか、react-error-boundaryを使います。react-error-boundaryを使えば、自分のコードにクラスを書かずに済みます。

Q. try/catchでコンポーネントを囲めば、同じことができますか? A. JSXを try/catch で囲んでも、子コンポーネントの描画エラーは受け取れません。JSXを書いた時点では要素を作っているだけで、子の関数が呼ばれるのはそのあとだからです。描画中のエラーにはError Boundaryを、イベントハンドラや非同期処理の中のエラーには try/catch を使う、と分けて考えます。

Q. react-error-boundaryはReact 18でも使えますか? A. 今回入れた6.1.6のpackage.jsonでは、peerDependenciesが react: ^18.0.0 || ^19.0.0 になっています。この記事の実行結果はReact 19.3.0でのものです。createRoot の onCaughtError と onUncaughtError、use() はReact 19の機能です。

まとめ

  • Error Boundaryが捕まえるのは、Reactが呼び出す処理の中のエラー(描画中、useEffectの同期部分、refコールバック)
  • イベントハンドラ、setTimeout、Promise、サーバー側の描画、境界自身のフォールバックは対象外。 今回の実行でも、画面は変わらずwindowのイベントに届いた
  • 境界へ渡す方法は3つ。 showBoundary、useTransition のstartTransition、use() と Suspense
  • 再試行は resetErrorBoundary、対象が変わったら resetKeys。 onReset でエラーの原因を取り除く
  • React 19の onCaughtError / onUncaughtError で、ルートの1か所からすべてのエラーを受け取れる。 境界が捕まえたエラーはwindowのerrorイベントに届かないため、ログはここから送る
  • 境界は「アプリ全体」「ページ」「独立した部品」の3段で置く。 いちばん外側には固定表示の境界を1つ
  • react-error-boundary 6の error は unknown 型。 getErrorMessage で読む

参考・出典

  • React公式ドキュメント「Component」(Catching rendering errors with an Error Boundary、componentDidCatch、static getDerivedStateFromError) https://react.dev/reference/react/Component
  • React公式ドキュメント「createRoot」(onCaughtError、onUncaughtError、onRecoverableError) https://react.dev/reference/react-dom/client/createRoot
  • react-error-boundary 6.1.6 に同梱の型定義とpackage.json(npm)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React Error Boundaryの使い方|非同期エラー対応」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?