T-CREATOR

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

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 する
  • ユーザーフレンドリーなエラーメッセージを表示する
  • 再試行やナビゲーションのオプションを提供する

これらの実装により、エラーが発生してもユーザーが迷うことなく、開発者も問題を迅速に特定・解決できるアプリケーションを構築できるでしょう。

関連リンク