18
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-06-16

:tea: 前置き

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

誰向けの記事か

Next.js の AppRouter を使っていてエラーハンドリングの方法に迷っている方

環境

"next": "14.2.4"
"react": "18",
"typescript": "5",

結論

以下のように実装しました(ベストプラクティスかどうかは分かりません)。

  • ServerComponent から ClientComponent にカスタムエラーをシリアライズして渡し、ClientComponent で再度カスタムエラーとしてスローする

:computer: 実装手順

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

全てのエラーをキャッチする error.tsx

Next.js のドキュメントを見ると、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 を自分のコードにも追加しました。
ドキュメントにあるコードをそのまま持って来ています。

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 が存在しますが今回詳細説明は省きます。こちらドキュメントです。

Server Component でエラーが発生させてみた。

ServerComponent("use client" を使用していない)であるpage.tsx でエラーを発生するようにしました。

page.tsx の作成

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

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 を発生させます。

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

カスタムエラーの導入

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

カスタムエラーの定義

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

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';
  }
}

ここでは単純化のため 404NotFoundError だけ定義しましたが実際には他の種類のエラーも定義しています。

error.tsx の修正

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

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 する

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

クラアントコンポーネントからサーバーコンポーネントへの受け渡し

先ほどの挙動から、ServerComponentで起きたカスタムエラーが ClientComponent である Error.tsx に渡るとカスタムエラーではなく Error クラスと扱われていることがわかりました。

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

カスタムエラーに serializedesirialize メソッドを持たせる

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を作成します。

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コンポーネントを利用して丁寧にハンドリングできるよう処理を追加します。

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

:star: 終わりに

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

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

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

:pray: 参考にさせていただいたサイト

18
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?