- React
React Error Boundaryの使い方|非同期エラー対応
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期連続の黒字決算。