try-catch の書き方で「こうしたらスッキリ書ける!」と気づけた事例がありましたので、今後みなさんがコードを書くときやレビューするときの糧にしていただけたら幸いです。
React Router の loader と ErrorBoundary
本題の前に、この記事が前提とする React Router の仕組みを簡単に整理します。
React Router の v6.4 以降では、ルートごとに loader という関数を定義できます。
これは画面が描画される前にデータを取得しておくための関数で、コンポーネントの useEffect で fetch する代わりに、ルート定義側でデータの準備を済ませられるのが特徴です。
// ルート定義のイメージ
{
path: "/users/:id",
loader: userLoader, // ← 描画前にここでデータ取得
element: <UserPage />,
errorElement: <ErrorPage />, // ← エラー時に表示される画面
}
そしてもう一つ、errorElement(ErrorBoundary)という仕組みがあります。
これはルートの処理中に投げられたエラーをキャッチして、エラー画面を表示してくれるものです。この 2 つが、今回の話の土台になります。
今回題材にしたいコード
loader と try-catch を利用して下記のような処理を書いたとします。
export async function loader() {
try {
const data = await fetchSomething();
if (!data) {
throw new Response("Not Found", { status: 404 });
}
return data;
} catch (error) {
if (error instanceof Response) {
throw error; // Response はそのまま投げ直す
}
console.error(error);
throw new Response("Server Error", { status: 500 });
}
}
通信が失敗するかもしれないし、データが無いかもしれない。
エラーが起きたら困るから、とりあえず try-catch で囲んでおこう。
という気持ちで書くと上記のようなコードになります。
実はこのコードは try-catch を利用することで、むしろコードを読みにくくしていました…。
どう書き換えるとシンプルになるのかを整理していきます。
先に結論
loader の中で「自分でハンドリングすべきエラー」(データなし、バリデーション失敗)だけを明示的に throw し、それ以外のエラーは try-catch せずフレームワークに任せます。
何が問題だったのか
冒頭のコードには、3 つの問題が潜んでいます。
問題1: 自分で投げたものを、自分で拾って投げ直している
try ブロックの中で throw したものは、すべて同じ try の catch に入ります。
try {
throw new Response("Not Found", { status: 404 }); // ← (A) ここで投げると
} catch (error) {
// ← (B) 必ずここに来る
if (error instanceof Response) {
throw error; // ← (C) 同じ Response をもう一回投げ直すだけ
}
}
(A) で投げた 404 を、(B) の catch が拾って、(C) でまた投げ直しているだけ。
これは 「自分で投げて、自分で拾って、また投げる」という回り道でしかありません。
問題2: そもそも未処理エラーはフレームワークが拾ってくれる
「でも catch しないと、予期しないエラー(fetch の失敗など)が握りつぶされるのでは?」と思うかもしれません。
ここが肝心なところで、React Router は loader 内の未処理エラーを自動的に errorElement(ErrorBoundary)へ渡してくれます。
つまり、fetchSomething() が予期せず例外を投げても、try-catch がなくてもエラーはきちんとハンドリングされ、エラー画面に到達します。
catch して 500 に変換する処理は、フレームワークがやってくれることを手作業で再実装していただけなのです…。
問題3: エラー情報がむしろ失われる
catch (error) {
console.error(error); // 詳細はコンソールにしか出さない
throw new Response("Server Error", { status: 500 }); // ユーザーには汎用メッセージ
}
元のエラーを console.error に流して、レスポンスには汎用メッセージしか載せていません。
一見「丁寧なエラーハンドリング」に見えます。
React Router は、開発時にはエラーのスタックトレースを表示し、本番では情報漏洩を防ぐため自動でサニタイズします。
自前で 500 エラーを返してしまうと、この開発時の便利なデバッグ表示を潰してしまうのです。
書き換え後
try-catch を丸ごと外して、こうしました。
import { data } from "react-router";
export async function loader() {
const result = await fetchSomething();
// 自分でハンドリングすべきものだけ明示的にチェック
const raw = Array.isArray(result) ? result[0] : result;
if (!raw) {
throw new Response("Not Found", { status: 404 });
}
const parsed = SomeSchema.safeParse(raw);
if (!parsed.success) {
throw data(null, { status: 404 });
}
return parsed.data;
}
ネストが消えてフラットになり、「何をハンドリングしたいのか」が一目で分かるようになりました。
data() は React Router が提供するヘルパーで、ステータスコード付きのレスポンスを返したいときに使えます。
throw data(null, { status: 404 }) のように投げると、ErrorBoundary 側で isRouteErrorResponse を使って拾えます。
なぜこれで十分なのか
ケースごとに、Before / After の挙動を並べてみます。
| ケース | Before(try-catch あり) | After(try-catch なし) |
|---|---|---|
| データなし | try 内で 404 → catch で再スロー | 直接 404 を throw |
| バリデーション失敗 | try 内で 500 → catch で再スロー | 直接 404 を throw |
| 接続エラー | catch で 500 に変換 | React Router が自動処理 |
| 予期しないエラー | catch で 500 に変換 | React Router が自動処理 |
どのケースも、最終的な挙動は変わりません。
フレームワークに任せるだけでコードは短く、デバッグ情報も保たれます。
学び: 「フレームワークが守ってくれる範囲」を知る
今回の本質は、try-catch そのものが悪いわけではないということです。
問題は、フレームワークが既にやってくれることを知らずに、手作業で再実装してしまっていたこと。
React Router の「loader の未処理エラーは ErrorBoundary に流れる」という仕様を理解していれば、catch が不要だと判断できます。
エラーハンドリングを書く前に
- このエラーは「自分が意図的にハンドリングすべきもの」か
- それとも「フレームワークに任せたほうがいいもの」か
を問い直せると良いでしょう。
意図的にハンドリングすべきエラー(データなし、バリデーション失敗)だけを明示的にチェックし、それ以外はフレームワークに委ねる。
これが、loader をシンプルに保つコツでした。
まとめ
- React Router の loader 内の未処理エラーは、自動で ErrorBoundary に渡される
- 自前で 500 エラーに変換すると、開発時のスタックトレース表示まで潰してしまう
- 「自分でハンドリングすべきエラーだけ明示し、あとは任せる」とコードがフラットになる
AI に任せることが多くなった昨今ですが、良いコードの判断は自分でできるようにしておきたいですね!
まだまだ精進します!