Next.jsのエラーハンドリング: サーバーからクライアントまで

# :tea: 前置き
Next.js の AppRouter を使いましたが、エラーハンドリング周りでかなり頭を悩ませました。
調べたりベストな方法がわかりませんでしたが、調べ実際に実装したエラーハンドリングについて記載します。
もしこうした方が良いなどアイデア等ございましたらご指摘頂けますと幸いです。

## 誰向けの記事か
Next.js の AppRouter を使っていてエラーハンドリングの方法に迷っている方

## 環境
```
"next": "14.2.4"
"react": "18",
"typescript": "5",
```

## 結論
以下のように実装しました（ベストプラクティスかどうかは分かりません）。
- `ServerComponent` から `ClientComponent` にカスタムエラーをシリアライズして渡し、`ClientComponent` で再度カスタムエラーとしてスローする


# :computer: 実装手順
実際に私が実装していった大まかな流れで記載していきます。
文章的に読みにくい点も多々あると思いますがご了承ください。

## 全てのエラーをキャッチする error.tsx
[Next.js のドキュメント](https://nextjs.org/docs/app/api-reference/file-conventions/error)を見ると、`error.tsx` を作成することで想定しないエラーをキャッチすることが分かります。 
> It is useful for catching unexpected errors that occur in Server Components and Client Components and displaying a fallback UI.

### error.tsx の作成
実際に `error.tsx` を自分のコードにも追加しました。
ドキュメントにあるコードをそのまま持って来ています。

```tsx: src/app/error.tsx
'use client';
import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Log the error to an error reporting service
    console.error(error);
  }, [error]);

  return (
    <div>
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button
        onClick={
          // Attempt to recover by trying to re-render the segment
          () => reset()
        }
      >
        Try again
      </button>
    </div>
  );
}
```

※ 別途 `global-error.tsx` が存在しますが今回詳細説明は省きます。こちら[ドキュメント](https://nextjs.org/docs/app/api-reference/file-conventions/error#global-errorjs)です。

## Server Component でエラーが発生させてみた。
ServerComponent（"use client" を使用していない）である`page.tsx` でエラーを発生するようにしました。

### page.tsx の作成
今回は例として、API を fetch して Todo の一覧を表示すると仮定して実装していきます。

```tsx: src/app/(route)/todos/page.tsx
import { fetchApi } from '@/app/_utils/fetchApi';
import { TodoList } from '@/components/TodoList';
import { Todo } from '@/types/Todo';

async function fetchTodos(): Promise<Todo[]> {
  return await fetchApi('https://jsonplaceholder.typicode.com/todos');
}

export default async function Todos() {
  const todos = await fetchTodos();

  return (
    <div>
      <h1>Todos</h1>
      <TodoList todos={todos} />
    </div>
  );
}

```

### fetchApi 関数でのエラー発生
`fetchApi` で Error を発生させます。

```ts: src/app/_utils/fetchApi.ts
export async function fetchApi(url: string) {
  throw new Error('テストのエラーです'); // ここで発生させる
  const response = await fetch(url);
  if (response.ok) {
    return response.json();
  }
  const errorText = await response.text();
  throw new Error(errorText);
  try {
    // レスポンスがJSONの場合はパース
    const errorData = JSON.parse(errorText);
    throw new Error(errorData.message);
  } catch (e) {
    // JSON以外のレスポンスの場合
    throw new Error(errorText, { cause: e });
  }
}
```

以下のように `error.tsx` がエラーをキャッチしてエラーメッセージを表示しました。

![Screenshot 2024-06-15 at 17.36.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488575/d6107432-58c9-7331-5f78-8137f30c89fb.png)


## カスタムエラーの導入
API から `404` が返ってきた場合には、「リソースが見つかりません。」というタイトルと「指定されたリソースは存在しないか、アクセスできません。」というメッセージを表示したい。

### カスタムエラーの定義
まず `Error` クラスを拡張した `APIError` を作成し、タイトルと詳細を入れる `title` と `description` を作成します。
そして、その `APIError` を拡張する形で特定のエラークラスを作成しました。

```ts: src/app/_utils/customeError.ts
export type APIErrorObject = {
  name: string;
  message: string;
  title: string; // Frontend で表示するエラータイトル
  description: string; // Frontend で表示するエラー詳細
};

export class APIError extends Error {
  title: string;
  description: string;
  errorCode?: string;
  detail?: string;

  constructor(props: APIErrorObject) {
    super(props.message);
    this.name = props.name;
    this.title = props.title;
    this.description = props.description;
  }
}

export class NotFoundError extends APIError {
  constructor(props: APIErrorObject) {
    super(props);
    this.name = 'NotFoundError';
  }
}
```
:::note
ここでは単純化のため `404` の `NotFoundError` だけ定義しましたが実際には他の種類のエラーも定義しています。
:::

### error.tsx の修正

先ほど定義した `APIError` であれば `title` と `description` が表示できるため条件分岐を入れて修正してみます。

```diff_tsx: src/app/error.tsx
'use client';
import { useEffect } from 'react';
import { APIError } from './_utils/customError';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Log the error to an error reporting service
    console.error(error);
  }, [error]);

  return (
    <div>
-     <h2>Something went wrong!</h2>
+     <h2>{error instanceof APIError ? error.title : error.name}</h2>
-     <p>{error.message}</p>
+     <p>{error instanceof APIError ? error.description : error.message}</p>
      <button
        onClick={
          // Attempt to recover by trying to re-render the segment
          () => reset()
        }
      >
        Try again
      </button>
    </div>
  );
}
```

### fetchApi でカスタムエラーを throw する

```diff_tsx: src/app/_utils/fetchApi.ts
export async function fetchApi(url: string) {
+ throw new NotFoundError({
+   name: 'NotFoundError',
+   message: 'Not Found',
+   title: 'リソースが見つかりません。',
+   description: '指定されたリソースは存在しないか、アクセスできません。',
+ });
- throw new Error('テストのエラーです'); // ここで発生させる
  const response = await fetch(url);
  if (response.ok) {
    return response.json();
  }
  const errorText = await response.text();
  throw new Error(errorText);
  try {
    // レスポンスがJSONの場合はパース
    const errorData = JSON.parse(errorText);
    throw new Error(errorData.message);
  } catch (e) {
    // JSON以外のレスポンスの場合
    throw new Error(errorText, { cause: e });
  }
}
```

再度 http://localhost:3000/todos にアクセスすると、タイトルと詳細が表示されません。
`incetanceOf` の条件判定がうまくいっていないようです。

![Screenshot 2024-06-15 at 18.11.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488575/c102aae9-2bb2-ff1d-55bb-1ef8df8ba641.png)

## クラアントコンポーネントからサーバーコンポーネントへの受け渡し
先ほどの挙動から、`ServerComponent`で起きたカスタムエラーが `ClientComponent` である `Error.tsx` に渡るとカスタムエラーではなく `Error` クラスと扱われていることがわかりました。

これを防ぐために`ClientComponent`から、`ServerComponent`へシリアライズしたエラーを渡して、`ClientComponent`内で再度カスタムエラーとして Throw する方向で考えます。

### カスタムエラーに `serialize`と`desirialize` メソッドを持たせる

```diff_tsx: src/app/_utils/customError.ts

export type APIErrorObject = {
  name: string;
  message: string;
  title: string; // Frontend で表示するエラータイトル
  description: string; // Frontend で表示するエラー詳細
};

export class APIError extends Error {
  title: string;
  description: string;
  errorCode?: string;
  detail?: string;

  constructor(props: APIErrorObject) {
    super(props.message);
    this.name = props.name;
    this.title = props.title;
    this.description = props.description;
  }

+  serialize() {
+   return {
+     className: this.constructor.name,
+     name: this.name,
+     message: this.message,
+     title: this.title,
+     description: this.description,
+     errorCode: this.errorCode,
+     detail: this.detail,
+   };
+ }
+
+ static deserialize(data: any) {
+   const errorClass = this.getErrorClass(data.className);
+   return new errorClass({
+     name: data.name,
+     message: data.message,
+     title: data.title,
+     description: data.description,
+   });
+ }
+
+ private static getErrorClass(className: string) {
+   switch (className) {
+     case 'NotFoundError':
+       return NotFoundError;
+     default:
+       return APIError;
+   }
+ }
}

export class NotFoundError extends APIError {
  constructor(props: APIErrorObject) {
    super(props);
    this.name = 'NotFoundError';
  }
}
```

### ServerComponentから受け取ったエラーオブジェクトを再度throwするクライアントコンポーネントを作成する

`ServerComponent`から受け取ったエラーを`ClientComponent`の中で再度カスタムエラーとしてthrowするためだけの`ClientComponent`を作成します。

```tsx: src/app/components/DesirializeError.tsx
'use client';

import { APIError, APIErrorObject } from '@/app/_utils/customError';

interface DeserializeErrorProps {
  apiErrorObject: APIErrorObject;
}

export default function DeserializeError({
  apiErrorObject,
}: DeserializeErrorProps) {
  throw APIError.deserialize(apiErrorObject);
  return <></>;
  // UIは表示しない
}

```
このように書くことでシリアライズ化されたカスタムエラーを再度throwすることができます。

### サーバーコンポーネントでAPIErrorのハンドリングを追加する
ハンドリングをしている `APIError` については、先ほど作成した `DesirializeError`コンポーネントを利用して丁寧にハンドリングできるよう処理を追加します。

```diff_tsx: src/app/(route)/todos/page.tsx
import { APIError } from '@/app/_utils/customError';
import { fetchApi } from '@/app/_utils/fetchApi';
import DeserializeError from '@/components/DeserializeError';
import { TodoList } from '@/components/TodoList';
import { Todo } from '@/types/Todo';

async function fetchTodos(): Promise<Todo[]> {
  return await fetchApi('https://jsonplaceholder.typicode.com/todos');
}

export default async function Todos() {
+ let todos: Todo[] = [];
+ try {
+   todos = await fetchTodos();
+ } catch (error: any) {
+   if (error instanceof APIError) {
+     return <DeserializeError apiErrorObject={error.serialize()} />;
+     // クライアントコンポーネントでは instanceof でエラークラスを判定
+   }
+   throw error;
+   // APIError 以外のエラーは再スロー
+ }

  return (
    <div>
      <h1>Todos</h1>
      <TodoList todos={todos} />
    </div>
  );
}

```

### エラーが適切にハンドリングできるか再度確認
ここまで実装するとカスタムエラーが適切に`ClientComponent`内で再度throwされることエラーページにタイトルと説明を表示することができます。

![Screenshot 2024-06-15 at 18.59.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488575/8e11038c-dbc6-a3c1-8933-ab90d4fd75ff.png)

# :star: 終わりに
この記事では、Next.js の AppRouter を使用したエラーハンドリングの実装方法について説明しました。カスタムエラーを定義し、それをシリアライズしてクライアントコンポーネントに渡すことで、適切なエラーメッセージを表示する方法を実装しました。

エラーハンドリングはアプリケーションの信頼性を高めるために重要です。特に複雑なアプリケーションでは、予期しないエラーが発生する可能性が高く、それをユーザーに適切に通知することが求められます。今回紹介した方法は、カスタムエラーを使ってより詳細なエラーメッセージを提供することで、ユーザー体験を向上させる一助となります。

この記事がエラーハンドリングの参考になれば幸いです。改善点や新しいアイデアがあれば、ぜひコメントで教えてください。

# :pray: 参考にさせていただいたサイト
- [[Next.js v13][和訳] Server & Client Components](https://zenn.dev/hamo/articles/31133919b987b9)
- [Next.js の Error を丁寧に扱う](https://zenn.dev/takepepe/articles/nextjs-error-handling)