Remix の ErrorBoundary で堅牢なエラーハンドリング

Web アプリケーションを開発していると、予期しないエラーが発生してアプリケーション全体がクラッシュしてしまうことがありませんか。ユーザーが白い画面を見て困惑する前に、適切なエラーハンドリングを実装することが重要です。
Remix では React の ErrorBoundary を進化させた強力なエラーハンドリング機能を提供しており、これを使うことで堅牢なアプリケーションを構築できます。本記事では、Remix の ErrorBoundary の基本的な実装方法から実践的な活用法まで詳しく解説いたします。
背景
Web アプリケーションでのエラーハンドリングの必要性
現代の Web アプリケーションは複雑化しており、API 通信エラー、データベース接続エラー、外部サービスの障害など、様々な要因でエラーが発生する可能性があります。これらのエラーを適切に処理しないと、アプリケーション全体が停止してしまい、ユーザーエクスペリエンスが大幅に悪化してしまいます。
エラーハンドリングは以下の理由で重要です:
# | 理由 | 詳細 |
---|---|---|
1 | ユーザビリティの向上 | エラー発生時でも適切なメッセージを表示し、ユーザーが次にとるべき行動を示す |
2 | アプリケーションの安定性 | 部分的なエラーがアプリケーション全体に影響しないよう分離する |
3 | デバッグの効率化 | エラー情報を詳細に記録し、問題の特定と修正を迅速に行う |
4 | 業務継続性 | 一部機能でエラーが発生しても、他の機能は正常に動作させる |
React の ErrorBoundary の基本概念
React では ErrorBoundary というコンポーネントを使用してエラーをキャッチできます。従来の ErrorBoundary は以下のような特徴があります:
ErrorBoundary の仕組みを図で表すと以下のようになります:
mermaidflowchart TD
app[App コンポーネント] --> error[ErrorBoundary]
error --> component1[コンポーネント A]
error --> component2[コンポーネント B]
component1 -->|エラー発生| catch[エラーをキャッチ]
catch --> fallback[フォールバック UI 表示]
component2 --> normal[正常動作継続]
React の ErrorBoundary はコンポーネントツリー内でエラーをキャッチし、アプリケーション全体のクラッシュを防ぎます。
しかし、従来の React ErrorBoundary には以下の制限がありました:
- イベントハンドラー内のエラーはキャッチできない
- 非同期処理(Promise)のエラーはキャッチできない
- サーバーサイドでのエラーハンドリングが困難
Remix における ErrorBoundary の特徴と利点
Remix は React の ErrorBoundary を拡張し、より実用的なエラーハンドリング機能を提供しています。
mermaidflowchart LR
server[サーバー] -->|loader/action エラー| boundary[Remix ErrorBoundary]
client[クライアント] -->|コンポーネントエラー| boundary
boundary --> display[適切なエラー表示]
boundary --> logging[エラーログ記録]
boundary --> continue[他機能の継続動作]
Remix ErrorBoundary の主な利点は次のとおりです:
# | 特徴 | 説明 |
---|---|---|
1 | ファイルベースのエラーハンドリング | error.tsx ファイルでルート別にエラー処理を定義 |
2 | サーバーエラーの統合処理 | loader や action のエラーも同じ仕組みで処理 |
3 | 階層的エラーハンドリング | ネストしたルートで段階的にエラーを処理 |
4 | 型安全なエラー情報 | TypeScript と連携したエラー情報の取得 |
課題
ページエラーによるアプリケーション全体のクラッシュ
従来の Web アプリケーションでは、ページの一部でエラーが発生すると、そのエラーがアプリケーション全体に波及し、すべての機能が停止してしまうことがありました。
このような状況は以下の問題を引き起こします:
mermaidstateDiagram-v2
[*] --> Normal: アプリ起動
Normal --> Error: エラー発生
Error --> Crash: 全体クラッシュ
Crash --> [*]: ユーザー離脱
Normal --> PartialError: 部分エラー
PartialError --> Recovery: エラー境界で回復
Recovery --> Normal: 正常動作継続
上図は、ErrorBoundary がない場合(上の流れ)とある場合(下の流れ)のアプリケーションの動作を示しています。
ユーザーエクスペリエンスの悪化
エラーハンドリングが不十分な場合、ユーザーは以下のような体験をすることになります:
- 何の説明もない白い画面が表示される
- 操作が突然効かなくなる
- データが失われる可能性がある
- 問題の解決方法がわからない
デバッグ情報の不足
開発者にとっても、適切なエラーハンドリングがないと以下の問題が発生します:
# | 問題 | 影響 |
---|---|---|
1 | エラーの詳細情報が取得できない | 問題の特定に時間がかかる |
2 | エラーの発生箇所が特定できない | 修正対象が不明確 |
3 | エラーの再現条件がわからない | 効果的なテストができない |
4 | ユーザーへの影響範囲が把握できない | 適切な対応策を立てられない |
解決策
Remix ErrorBoundary の基本実装
Remix では error.tsx
ファイルを作成することで、そのルートとその子ルートで発生したエラーをキャッチできます。
基本的な ErrorBoundary の実装は以下のようになります:
typescript// app/routes/_index/error.tsx
import {
useRouteError,
isRouteErrorResponse,
} from '@remix-run/react';
export default function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div className='error-container'>
<h1>エラーが発生しました</h1>
<p>ステータス: {error.status}</p>
<p>メッセージ: {error.statusText}</p>
</div>
);
}
return (
<div className='error-container'>
<h1>予期しないエラーが発生しました</h1>
<p>申し訳ございませんが、問題が発生いたしました。</p>
</div>
);
}
上記のコードでは、useRouteError
フックを使用してエラー情報を取得し、エラーの種類に応じて適切なメッセージを表示しています。
エラーの種類を判定するためのヘルパー関数も実装できます:
typescript// app/utils/error.ts
export function getErrorMessage(error: unknown): string {
if (isRouteErrorResponse(error)) {
return `${error.status} ${error.statusText}`;
}
if (error instanceof Error) {
return error.message;
}
return '不明なエラーが発生しました';
}
export function getErrorDetails(error: unknown) {
if (isRouteErrorResponse(error)) {
return {
status: error.status,
statusText: error.statusText,
data: error.data,
};
}
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack,
};
}
return { message: '詳細不明' };
}
ルート別エラーハンドリング
Remix のファイルベースルーティングでは、各ルートに個別の ErrorBoundary を設定できます。これにより、エラーが発生したルートのみを適切に処理し、他の部分は正常に動作させることができます。
ルート構造と ErrorBoundary の配置例:
mermaidflowchart TD
root[app/root.tsx] --> dashboard[app/routes/dashboard.tsx]
root --> settings[app/routes/settings.tsx]
dashboard --> dashboardError[app/routes/dashboard/error.tsx]
settings --> settingsError[app/routes/settings/error.tsx]
root --> rootError[app/root.tsx ErrorBoundary]
この図は、各ルートレベルでエラーハンドリングを設定することで、障害を局所化できることを示しています。
ダッシュボードルートの ErrorBoundary 実装例:
typescript// app/routes/dashboard/error.tsx
import { useRouteError } from '@remix-run/react';
import { Link } from '@remix-run/react';
export default function DashboardErrorBoundary() {
const error = useRouteError();
return (
<div className='dashboard-error'>
<h2>ダッシュボードでエラーが発生しました</h2>
<p>
ダッシュボードの読み込み中に問題が発生いたしました。
</p>
<div className='error-actions'>
<Link to='/dashboard' className='retry-button'>
再試行
</Link>
<Link to='/' className='home-button'>
ホームに戻る
</Link>
</div>
</div>
);
}
グローバルエラーハンドリング
アプリケーション全体をカバーするグローバルな ErrorBoundary は app/root.tsx
に実装します:
typescript// app/root.tsx(一部抜粋)
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useRouteError,
isRouteErrorResponse,
} from '@remix-run/react';
export default function App() {
return (
<html lang='ja'>
<head>
<meta charSet='utf-8' />
<meta
name='viewport'
content='width=device-width,initial-scale=1'
/>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
root.tsx のグローバル ErrorBoundary 実装:
typescriptexport function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<html lang='ja'>
<head>
<title>エラー - {error.status}</title>
<Meta />
<Links />
</head>
<body>
<div className='global-error'>
<h1>アプリケーションエラー</h1>
<p>
申し訳ございませんが、問題が発生いたしました。
</p>
<p>エラーコード: {error.status}</p>
<button
onClick={() => window.location.reload()}
>
ページを再読み込み
</button>
</div>
<Scripts />
</body>
</html>
);
}
return (
<html lang='ja'>
<head>
<title>予期しないエラー</title>
<Meta />
<Links />
</head>
<body>
<div className='global-error'>
<h1>予期しないエラーが発生しました</h1>
<p>技術的な問題が発生いたしました。</p>
<button
onClick={() => (window.location.href = '/')}
>
ホームに戻る
</button>
</div>
<Scripts />
</body>
</html>
);
}
具体例
シンプルな ErrorBoundary の実装
最もシンプルな ErrorBoundary から実装を始めましょう。商品一覧ページのエラーハンドリング例です:
typescript// app/routes/products._index.tsx
import {
json,
type LoaderFunctionArgs,
} from '@remix-run/node';
import {
useLoaderData,
useRouteError,
} from '@remix-run/react';
// データを取得する loader 関数
export async function loader({
request,
}: LoaderFunctionArgs) {
try {
const response = await fetch(
'https://api.example.com/products'
);
if (!response.ok) {
throw new Response('商品データの取得に失敗しました', {
status: response.status,
statusText: response.statusText,
});
}
const products = await response.json();
return json({ products });
} catch (error) {
console.error('Products loader error:', error);
throw new Response('サーバーエラーが発生しました', {
status: 500,
});
}
}
商品一覧コンポーネントの実装:
typescript// メインコンポーネント
export default function ProductsIndex() {
const { products } = useLoaderData<typeof loader>();
return (
<div>
<h1>商品一覧</h1>
<div className='products-grid'>
{products.map((product) => (
<div key={product.id} className='product-card'>
<h3>{product.name}</h3>
<p>{product.description}</p>
<span className='price'>
¥{product.price.toLocaleString()}
</span>
</div>
))}
</div>
</div>
);
}
対応する ErrorBoundary の実装:
typescript// ErrorBoundary コンポーネント
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div className='products-error'>
<h2>商品の読み込みでエラーが発生しました</h2>
<p>エラーコード: {error.status}</p>
<p>
{error.data ||
'商品データを取得できませんでした。'}
</p>
<div className='error-actions'>
<button onClick={() => window.location.reload()}>
再読み込み
</button>
<Link to='/'>ホームに戻る</Link>
</div>
</div>
);
}
return (
<div className='products-error'>
<h2>予期しないエラーが発生しました</h2>
<p>
申し訳ございませんが、商品一覧の表示中に問題が発生いたしました。
</p>
</div>
);
}
loader エラーのハンドリング
loader でのエラーハンドリングは、データ取得時の問題を適切に処理するために重要です。
以下は、ユーザー詳細ページでの loader エラーハンドリングの例です:
typescript// app/routes/users.$userId.tsx
import {
json,
type LoaderFunctionArgs,
} from '@remix-run/node';
import {
useLoaderData,
useRouteError,
useParams,
} from '@remix-run/react';
export async function loader({
params,
}: LoaderFunctionArgs) {
const { userId } = params;
// パラメーターの検証
if (!userId || isNaN(Number(userId))) {
throw new Response('無効なユーザーIDです', {
status: 400,
});
}
try {
const user = await getUserById(Number(userId));
if (!user) {
throw new Response('ユーザーが見つかりません', {
status: 404,
});
}
return json({ user });
} catch (error) {
if (error instanceof Response) {
throw error; // Response エラーはそのまま throw
}
console.error('User loader error:', error);
throw new Response(
'ユーザー情報の取得中にエラーが発生しました',
{
status: 500,
}
);
}
}
getUserById 関数の実装例:
typescript// app/utils/user.server.ts
export async function getUserById(id: number) {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${id}`
);
if (!response.ok) {
throw new Error(
`HTTP ${response.status}: ${response.statusText}`
);
}
return response.json();
}
対応する ErrorBoundary:
typescriptexport function ErrorBoundary() {
const error = useRouteError();
const params = useParams();
if (isRouteErrorResponse(error)) {
if (error.status === 404) {
return (
<div className='user-not-found'>
<h2>ユーザーが見つかりません</h2>
<p>
ID「{params.userId}
」のユーザーは存在しないか、削除されている可能性があります。
</p>
<Link to='/users'>ユーザー一覧に戻る</Link>
</div>
);
}
if (error.status === 400) {
return (
<div className='invalid-user-id'>
<h2>無効なユーザーID</h2>
<p>
指定されたユーザーID「{params.userId}
」は無効です。
</p>
<Link to='/users'>ユーザー一覧に戻る</Link>
</div>
);
}
}
return (
<div className='user-error'>
<h2>ユーザー情報の読み込みエラー</h2>
<p>
ユーザー情報を取得できませんでした。しばらく時間をおいて再度お試しください。
</p>
</div>
);
}
action エラーのハンドリング
フォーム送信やデータ更新時のエラーハンドリングも重要です。以下は、ユーザー登録フォームでの action エラーハンドリング例です:
typescript// app/routes/register.tsx
import {
json,
redirect,
type ActionFunctionArgs,
} from '@remix-run/node';
import {
Form,
useActionData,
useRouteError,
} from '@remix-run/react';
export async function action({
request,
}: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get('email');
const password = formData.get('password');
// バリデーション
const errors: { email?: string; password?: string } = {};
if (!email || typeof email !== 'string') {
errors.email = 'メールアドレスは必須です';
}
if (
!password ||
typeof password !== 'string' ||
password.length < 8
) {
errors.password =
'パスワードは8文字以上で入力してください';
}
if (Object.keys(errors).length > 0) {
return json({ errors }, { status: 400 });
}
try {
await createUser({ email, password });
return redirect('/dashboard');
} catch (error) {
console.error('Registration error:', error);
if (
error instanceof Error &&
error.message.includes('duplicate')
) {
return json(
{
errors: {
email:
'このメールアドレスは既に使用されています',
},
},
{ status: 409 }
);
}
throw new Response(
'ユーザー登録中にエラーが発生しました',
{ status: 500 }
);
}
}
フォームコンポーネントの実装:
typescriptexport default function Register() {
const actionData = useActionData<typeof action>();
return (
<div className='register-form'>
<h1>ユーザー登録</h1>
<Form method='post'>
<div className='form-group'>
<label htmlFor='email'>メールアドレス</label>
<input
type='email'
id='email'
name='email'
className={
actionData?.errors?.email ? 'error' : ''
}
/>
{actionData?.errors?.email && (
<span className='error-message'>
{actionData.errors.email}
</span>
)}
</div>
<div className='form-group'>
<label htmlFor='password'>パスワード</label>
<input
type='password'
id='password'
name='password'
className={
actionData?.errors?.password ? 'error' : ''
}
/>
{actionData?.errors?.password && (
<span className='error-message'>
{actionData.errors.password}
</span>
)}
</div>
<button type='submit'>登録</button>
</Form>
</div>
);
}
action エラー用の ErrorBoundary:
typescriptexport function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div className='register-error'>
<h2>登録処理でエラーが発生しました</h2>
<p>
{error.data ||
'ユーザー登録を完了できませんでした。'}
</p>
<div className='error-actions'>
<button onClick={() => window.location.reload()}>
再試行
</button>
<Link to='/login'>ログインページへ</Link>
</div>
</div>
);
}
return (
<div className='register-error'>
<h2>システムエラーが発生しました</h2>
<p>
申し訳ございませんが、登録処理中に技術的な問題が発生いたしました。
</p>
</div>
);
}
カスタムエラーページの作成
より洗練されたエラーページを作成するために、共通のエラーコンポーネントを作成できます:
typescript// app/components/ErrorDisplay.tsx
import { Link } from '@remix-run/react';
interface ErrorDisplayProps {
title: string;
message: string;
errorCode?: string | number;
showRetry?: boolean;
showHome?: boolean;
customActions?: React.ReactNode;
}
export function ErrorDisplay({
title,
message,
errorCode,
showRetry = true,
showHome = true,
customActions,
}: ErrorDisplayProps) {
return (
<div className='error-display'>
<div className='error-icon'>⚠️</div>
<h2>{title}</h2>
<p>{message}</p>
{errorCode && (
<div className='error-code'>
エラーコード: {errorCode}
</div>
)}
<div className='error-actions'>
{showRetry && (
<button
onClick={() => window.location.reload()}
className='retry-button'
>
再試行
</button>
)}
{showHome && (
<Link to='/' className='home-button'>
ホームに戻る
</Link>
)}
{customActions}
</div>
</div>
);
}
カスタムエラーコンポーネントを使用した ErrorBoundary:
typescript// app/routes/api.data/error.tsx
import {
useRouteError,
isRouteErrorResponse,
} from '@remix-run/react';
import { ErrorDisplay } from '~/components/ErrorDisplay';
export default function ApiErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
const customActions = (
<Link to='/contact' className='contact-button'>
サポートに連絡
</Link>
);
return (
<ErrorDisplay
title='API エラーが発生しました'
message='データの取得中に問題が発生いたしました。時間をおいて再度お試しください。'
errorCode={error.status}
customActions={customActions}
/>
);
}
return (
<ErrorDisplay
title='システムエラー'
message='申し訳ございませんが、システムで問題が発生いたしました。'
showRetry={false}
/>
);
}
エラーページのスタイリング例:
css/* app/styles/error.css */
.error-display {
max-width: 600px;
margin: 2rem auto;
padding: 2rem;
text-align: center;
border: 1px solid #e74c3c;
border-radius: 8px;
background-color: #fdf2f2;
}
.error-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.error-display h2 {
color: #c0392b;
margin-bottom: 1rem;
}
.error-code {
background-color: #f8f9fa;
padding: 0.5rem;
border-radius: 4px;
margin: 1rem 0;
font-family: monospace;
color: #6c757d;
}
.error-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1.5rem;
}
.retry-button,
.home-button,
.contact-button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
text-decoration: none;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.retry-button {
background-color: #3498db;
color: white;
}
.home-button {
background-color: #95a5a6;
color: white;
}
.contact-button {
background-color: #e67e22;
color: white;
}
まとめ
Remix の ErrorBoundary を活用することで、堅牢なエラーハンドリングシステムを構築できます。ファイルベースのエラーハンドリング、サーバーサイドエラーの統合処理、階層的なエラー境界により、ユーザーエクスペリエンスを大幅に改善できます。
重要なポイントは以下の通りです:
error.tsx
ファイルでルート別にエラーハンドリングを実装するuseRouteError
フックを使用してエラー情報を取得する- loader と action のエラーを適切に throw する
- ユーザーフレンドリーなエラーメッセージを表示する
- 再試行やナビゲーションのオプションを提供する
これらの実装により、エラーが発生してもユーザーが迷うことなく、開発者も問題を迅速に特定・解決できるアプリケーションを構築できるでしょう。
関連リンク
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来