Advanced (更新: 2026/6/7)

React Error Boundaryでウィジェット1個の例外から画面全体を守る

なぜtry/catchではレンダー中の例外を拾えないのか。Error Boundaryの仕組み、react-error-boundaryでの実装、フォールバックUI、リセット、ログ送信、置く粒度までコピペで動くコードで。

React Error Boundaryでウィジェット1個の例外から画面全体を守る

ダッシュボードの売上グラフが、本番でだけ落ちました。原因はAPIが時々返すnull。たったそれだけ。なのに画面は真っ白。グラフどころか、その横にあった請求リンクも、設定ボタンも、まとめて消えました。

僕はとっさにグラフの描画コードをtry/catchで囲みました。これで安心、と思った。翌日、また真っ白になりました。

そこで初めて知ったんです。Reactのレンダー中に投げられた例外は、try/catchでは捕まらないということを。捕まえるための専用の仕組みが、Error Boundary(エラーバウンダリ)です。日本語にするなら「画面の防火扉」。火元を消す道具ではなく、燃え広がりを1部屋で止める扉、と考えると一気に腑に落ちます。

この記事は、そのError Boundaryの仕組みと、react-error-boundaryというライブラリでの実装、フォールバックUI、リセット、ログ送信、そして「どの粒度で置くか」までを、僕がつまずいた順番で書きます。

この記事の要点

  • レンダー中の例外はtry/catchでは拾えない。Error Boundaryという別の仕組みが要る。
  • 仕組みの正体はgetDerivedStateFromError(フォールバック表示)とcomponentDidCatch(ログ送信)の2つだけ。
  • 自作もできるが、実務はreact-error-boundaryが速い。FallbackComponentonResetresetKeysが揃っている。
  • 画面全体に1個ではなく「壊れても作業が続く単位」に置く。グラフ・プレビュー・おすすめ枠が代表例。
  • ボタンや非同期処理の失敗は境界では拾えない。useErrorBoundaryshowBoundaryで手渡すか、普通のUIで見せる。

なぜ try/catch ではレンダーエラーが拾えないのか

try/catchは「上から順に実行される処理」を囲む構文です。でもReactのレンダーは、僕らが書いたJSXをReactが好きなタイミングで呼び出して組み立てる仕組みです。return <Chart data={data} />と書いた瞬間に描画が走るわけではありません。

だから、こう書いても無駄でした。

// これは効かない。レンダー中の例外は外まで飛んでこない
function Dashboard() {
  try {
    return <RevenueChart />; // ここで投げても catch に来ない
  } catch (e) {
    return <p>エラー</p>;
  }
}

<RevenueChart />の中でdata.points.map(...)datanullで落ちても、その例外はDashboardtryの外側、Reactの内部まで飛んでいきます。catchはもう実行が終わっているので、何も受け取れません。

Reactはこの「レンダー中に飛んできた例外」を受け止める場所を、別に用意しました。それがError Boundaryです。React公式のComponentリファレンスでも、レンダー・ライフサイクル・その配下の子で投げられた例外を境界が捕捉する、と整理されています。逆に言うと、境界が拾えるのはレンダーの世界の例外だけ。ここを取り違えると、いくら境界を足しても落ち続けます。

Error Boundary が拾えるもの・拾えないもの

最初に頭に入れておくと事故が減る対応表です。React公式の記載をもとに、実務での扱いを足しました。

例外の発生場所境界で拾える実務での扱い
子コンポーネントのレンダー中はいフォールバックUIを出し、ログを送る
useMemoやカスタムフックの実行中多くははい想定内の失敗は事前検証、想定外だけ境界へ
クリック・送信などのイベントハンドラいいえハンドラ内でtry/catch、必要なら境界へ手渡す
setTimeout・通常のPromise失敗いいえ失敗を明示的に処理し、再試行UIを用意する
サーバーサイドレンダリングいいえフレームワークのエラーページとHTTPステータスで扱う
境界自身のフォールバック内の例外いいえフォールバックは依存を減らし、上位にもう1枚置く

非同期に唯一の例外があります。useTransitionが返すstartTransitionの中で投げた例外は、境界が拾えます。React公式もここだけ別扱いと書いています。普段使うsetTimeoutやイベントハンドラは拾えない、と覚えておけば十分です。

エラー処理を「画面の防火扉」だけで考えると、APIの再試行やバリデーションの設計が抜けがちです。境界の外側も含めたエラー処理の全体設計は、エラー処理パターンを境界ごとに分類する記事に分けて書いたので、合わせて読むと粒度の判断が早くなります。

仕組みの正体は2つのメソッドだけ

身構えるほど複雑ではありません。Error Boundaryの本体は、クラスコンポーネントの2つのメソッドに集約されます。

  • static getDerivedStateFromError(error): 例外を受けて「フォールバックを出す状態」に切り替える。表示の切り替え担当。
  • componentDidCatch(error, info): ログ送信などの副作用を扱う。info.componentStackでどこで壊れたかが分かる。

最小の自作版はこれだけで動きます。仕組みを腹落ちさせるために、まず一度自分で書いてみるのをおすすめします。

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

type Props = { children: ReactNode; fallback: ReactNode };
type State = { hasError: boolean };

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

  // 例外を受けて「壊れた表示」に切り替える
  static getDerivedStateFromError(): State {
    return { hasError: true };
  }

  // ログ送信などの副作用はここで
  componentDidCatch(error: Error, info: ErrorInfo) {
    console.error("境界が捕捉:", error.message, info.componentStack);
  }

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

関数コンポーネントで書きたい気持ちは分かりますが、現状フックだけでError Boundaryは作れません。境界の「本体」だけクラスで用意して、普段の画面は関数コンポーネントのまま。この役割分担が現実解です。そして実務では、これを毎回手書きせずreact-error-boundaryに任せます。

react-error-boundary で実装する

react-error-boundaryは、上のクラスを書かずに済ませてくれる定番ライブラリです。FallbackComponentでフォールバックUIを渡し、onErrorでログ、onResetresetKeysでリセットを制御します。

npm install react-error-boundary

これがコピペでそのまま動く最小構成です。react-error-boundaryが渡してくれるerrorresetErrorBoundaryを使って、再試行ボタンまで含めています。

// src/components/WidgetBoundary.tsx
import { ErrorBoundary, type FallbackProps } from "react-error-boundary";
import type { ReactNode } from "react";

// フォールバックUI:謝罪だけで終わらせず、次の一手を出す
function WidgetFallback({ error, resetErrorBoundary }: FallbackProps) {
  return (
    <section role="alert" className="widget-fallback">
      <p>この部分を表示できませんでした。</p>
      <p>他の操作は続けられます。もう一度試してください。</p>
      <button type="button" onClick={resetErrorBoundary}>
        再試行
      </button>
      {/* ユーザー向けには error.stack を出さない。問い合わせ用のIDだけ */}
      <small>{error.name}</small>
    </section>
  );
}

type Props = { feature: string; children: ReactNode };

export function WidgetBoundary({ feature, children }: Props) {
  return (
    <ErrorBoundary
      FallbackComponent={WidgetFallback}
      onError={(error, info) => {
        // PIIを混ぜないログ送信(実装は後述の reportError に寄せる)
        console.error(`[${feature}]`, error.message, info.componentStack);
      }}
      onReset={() => {
        // リセット時にキャッシュ破棄や再取得をここで
      }}
    >
      {children}
    </ErrorBoundary>
  );
}

使う側はこう囲むだけです。

// src/routes/DashboardPage.tsx
import { WidgetBoundary } from "../components/WidgetBoundary";
import { RevenueChart } from "../widgets/RevenueChart";
import { NotificationPanel } from "../widgets/NotificationPanel";

export function DashboardPage() {
  return (
    <main>
      <WidgetBoundary feature="dashboard.revenue-chart">
        <RevenueChart />
      </WidgetBoundary>

      <WidgetBoundary feature="dashboard.notifications">
        <NotificationPanel />
      </WidgetBoundary>
    </main>
  );
}

これで、売上グラフがnullで落ちても、フォールバックが出るのはグラフの枠だけ。通知パネルも、その先の請求リンクも生き残ります。冒頭の「真っ白事故」は、ここで終わりました。

フォールバックUIとリセットを設計する

react-error-boundaryを入れたあと、僕が一番ハマったのがリセットでした。再試行ボタンを押しても、すぐ同じフォールバックに戻るんです。

理由は単純で、境界の状態だけリセットしても、壊れた入力がそのままだから。同じnullのデータ、同じ壊れたキャッシュが残っていれば、子は再レンダーでまた同じ例外を投げます。

直し方は2つ。1つはresetKeysに「入力が変わったら自動で復帰させたい値」を入れること。もう1つはonResetで再取得をかけること。

// src/components/ChartBoundary.tsx
import { ErrorBoundary } from "react-error-boundary";
import { RevenueChart } from "../widgets/RevenueChart";
import { ChartFallback } from "./ChartFallback";

type Props = { range: string; reload: () => void };

export function ChartBoundary({ range, reload }: Props) {
  return (
    <ErrorBoundary
      FallbackComponent={ChartFallback}
      // range(期間フィルタ)が変わったら境界を自動でリセット
      resetKeys={[range]}
      // 再試行ボタンが onReset を呼ぶので、ここで再取得する
      onReset={() => reload()}
    >
      <RevenueChart range={range} />
    </ErrorBoundary>
  );
}

フォールバックUI自体にもコツがあります。「申し訳ありません」だけでは、ユーザーは固まります。伝えるべきは3つ。作業内容が消えていないか/再試行していいか/同じならどうするか。デプロイ直後にだけ出るChunkLoadErrorのような失敗は、再試行より「アプリ全体を再読み込み」が効きます。エラーメッセージで出し分けると親切です。

// src/components/ChartFallback.tsx
import type { FallbackProps } from "react-error-boundary";

export function ChartFallback({ error, resetErrorBoundary }: FallbackProps) {
  // チャンク読み込み失敗は再読み込みが効く
  const needsReload = /ChunkLoadError|Loading chunk|imported module/i.test(
    error.message,
  );

  return (
    <section role="alert" className="chart-fallback">
      <h2>グラフを表示できませんでした</h2>
      <p>アカウントのデータは変更されていません。まず再試行してください。</p>
      <div className="chart-fallback__actions">
        <button type="button" onClick={resetErrorBoundary}>
          再試行
        </button>
        {needsReload ? (
          <button type="button" onClick={() => window.location.reload()}>
            アプリを再読み込み
          </button>
        ) : null}
      </div>
    </section>
  );
}

role="alert"を付けるのも忘れないでください。スクリーンリーダーが「いま何か起きた」と読み上げてくれます。フォールバックは壊れたときに出るUIなので、ここのアクセシビリティが抜けると一番困る人に届きません。

どの粒度で境界を置くか

「じゃあ全コンポーネントを囲めば最強では」と思いますよね。僕も一瞬そう考えて、ボタンやアイコンまで囲んで、逆にどこが壊れたか分からなくなりました。

境界は多すぎても少なすぎてもダメです。判断基準はこの3つに集約できました。

  1. その部分が壊れても、ユーザーは他の作業を続けられるか。 続けられる単位=境界の単位。
  2. その部分だけ再取得・初期化できるか。 単独で復帰できないものを個別に囲んでも意味が薄い。
  3. ログを見て、機能名やルート名で原因を切り分けられるか。 feature: "dashboard.revenue-chart"のような名前を付けておく。

実務での置きどころを具体例で挙げます。

  • ルート単位: ダッシュボード、設定、請求、記事編集など、画面の責務が変わる場所。ページ遷移ごとの安全網。React Routerならlocation.keyresetKeysに入れ、遷移したら古いエラー状態を捨てます。
  • 部品単位: 売上グラフ、通知パネル、Markdownプレビュー、外部API由来のおすすめ枠、巨大JSONのビューア。壊れやすく、かつ単独で復帰できる領域。
  • 囲まない: フォームの入力欄ひとつ、普通のボタン、見出し、アイコン。ここを個別に囲むのは過剰です。

境界の単位は、テストで「壊れた範囲」を再現する単位とも一致させると運用が楽になります。考え方はClaude Codeでのテスト戦略に寄せると揃います。決済ボタンの失敗のように「ユーザーに具体的な回復手順を出すべき失敗」は境界に送らず、次の章のやり方で手渡しします。

ボタンや非同期の失敗を境界に手渡す

ここが一番誤解されるところです。クリック処理やfetchの失敗は、Error Boundaryが自動では拾いません。レンダーの世界の外だからです。

選択肢は2つ。想定内の失敗(入力ミス・認証切れ・在庫不足・レート制限)は、トーストやフォームで普通に見せる。一方で想定外の例外だけを境界へ手渡す。手渡しにはreact-error-boundaryuseErrorBoundaryが用意したshowBoundaryを使います。

// src/components/SaveButton.tsx
import { useState } from "react";
import { useErrorBoundary } from "react-error-boundary";

type Props = { onSave: () => Promise<void> };

export function SaveButton({ onSave }: Props) {
  const [pending, setPending] = useState(false);
  // 非同期の例外を最寄りの境界へ手渡すための関数
  const { showBoundary } = useErrorBoundary();

  async function handleClick() {
    setPending(true);
    try {
      await onSave();
    } catch (error) {
      // 想定外だけ境界へ。バリデーション等はここで普通に処理する
      showBoundary(error);
    } finally {
      setPending(false);
    }
  }

  return (
    <button type="button" disabled={pending} onClick={handleClick}>
      {pending ? "保存中..." : "保存"}
    </button>
  );
}

何でもshowBoundaryに流すと、ユーザーは入力を直せないのに画面の一部が壊れて見えます。流すのは「壊れたレスポンス」「表示コンポーネントの不整合」「ありえないはずの例外」だけ。この線引きが効くと、境界が本当に効くべき場面でだけ発火します。

PIIを漏らさないログ送信

componentDidCatch(やonError)はログ送信に向いていますが、中身を絞らないと事故ります。冒頭の障害で、僕は調査ログにメールアドレス入りのURLを残してしまいました。window.location.hrefをそのまま送っていたからです。

PII(個人を特定できる情報。メール・氏名・電話番号・トークン・住所・カード番号など)を落とさないため、送る前にマスクします。送るのは機能名・リリース番号・ルートのパス・エラー名・短いメッセージ・コンポーネントスタックまで。クエリ文字列、フォーム入力値、Cookie、Authorizationヘッダー、APIレスポンス全文は送りません。

// src/lib/error-reporting.ts
const REDACTIONS: Array<[RegExp, string]> = [
  [/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[redacted-email]"],
  [/\b(?:\d[ -]*?){13,19}\b/g, "[redacted-number]"],
  [/\b(token|secret|password|authorization)=([^&\s]+)/gi, "$1=[redacted]"],
  [/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]"],
];

// メールやトークンらしき文字列を消す
export function redact(value?: string): string | undefined {
  if (!value) return value;
  return REDACTIONS.reduce((t, [re, rep]) => t.replace(re, rep), value);
}

export function reportError(
  error: Error,
  componentStack: string | undefined,
  feature: string,
) {
  const payload = {
    name: error.name,
    message: redact(error.message),
    stack: redact(error.stack),
    componentStack: redact(componentStack),
    // URLは pathname だけ。クエリは送らない
    route: typeof window === "undefined" ? "server" : window.location.pathname,
    feature,
  };
  const body = JSON.stringify(payload);

  // 離脱時でも送れるよう sendBeacon を優先
  if (typeof navigator !== "undefined" && navigator.sendBeacon) {
    if (navigator.sendBeacon("/api/client-errors", new Blob([body]))) return;
  }
  void fetch("/api/client-errors", {
    method: "POST",
    headers: { "content-type": "application/json" },
    credentials: "omit",
    keepalive: true,
    body,
  });
}

ブラウザ側だけのマスクは、将来の変更で簡単に穴が空きます。サーバー側でも同じ方針でもう一度マスクしてください。二重にやって初めて、漏れを止められます。スタックトレースの読み方そのものに不安があれば、スタックトレースをどこから読むかの記事に分けてまとめています。

よくある質問

Q. Error Boundaryとtry/catchはどう使い分けますか。 A. レンダー中に飛ぶ例外はError Boundary、イベントハンドラやfetchなど実行が追える処理はtry/catchです。後者の想定外だけshowBoundaryで境界へ手渡します。

Q. react-error-boundaryを入れず自作でも問題ないですか。 A. 仕組みの理解には自作が一番です。ただ実務ではFallbackComponentonResetresetKeysuseErrorBoundaryが揃ったreact-error-boundaryのほうが速く、リセット周りのバグを踏みにくいです。

Q. 境界はいくつ置けばいいですか。 A. 「壊れても他の作業が続く単位」に置きます。ルート単位を1枚、独立して壊れてよい部品(グラフ・プレビュー・おすすめ枠)に数枚が目安。ボタンやアイコン単位は過剰です。

Q. 開発中はフォールバックではなく例外オーバーレイが出ます。なぜですか。 A. 開発ビルドのReactは例外を再スローしてオーバーレイを見せます。境界自体は動いているので、本番ビルドで確認するか、テストでフォールバック表示を固定してください。

Q. Next.jsやRemixでも同じですか。 A. 考え方は同じですが、フレームワークがerror.tsxなどの専用ファイルやルート境界を持ちます。まずフレームワーク標準を使い、部品単位の隔離にreact-error-boundaryを足すのが噛み合います。

実際に試した結果

冒頭の真っ白事故から、僕の優先順位は変わりました。「とりあえず境界を置く」より、先にresetKeysとログのマスクを決める。順番を逆にした途端、レビューが速くなりました。

ダッシュボードのように複数ウィジェットが並ぶ画面では、効果がはっきり出ます。グラフがnullで落ちても止まるのはグラフの枠だけ。ユーザーは設定も請求も触れるし、僕はfeature: "dashboard.revenue-chart"というログ1行で原因の場所まで一直線でした。

Error Boundaryは「何でも捕まえる魔法」ではありません。レンダーの失敗を、ユーザー体験と運用ログに変換する境界です。拾える範囲、置く粒度、リセット条件、PIIを含まないログ。ここまで設計して初めて、防火扉として機能します。まずは一番落ちやすいウィジェット1個を、react-error-boundaryで囲むところから始めてみてください。

導入ルール作りやレビュー基準をチームで整えたいときは、研修・導入相談ものぞいてみてください。

#React #Error Boundary #react-error-boundary #TypeScript #フロントエンド
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。