前置き
Next.js の AppRouter を使いましたが、エラーハンドリング周りでかなり頭を悩ませました。
調べたりベストな方法がわかりませんでしたが、調べ実際に実装したエラーハンドリングについて記載します。
もしこうした方が良いなどアイデア等ございましたらご指摘頂けますと幸いです。
誰向けの記事か
Next.js の AppRouter を使っていてエラーハンドリングの方法に迷っている方
環境
"next": "14.2.4"
"react": "18",
"typescript": "5",
結論
以下のように実装しました(ベストプラクティスかどうかは分かりません)。
-
ServerComponent
からClientComponent
にカスタムエラーをシリアライズして渡し、ClientComponent
で再度カスタムエラーとしてスローする
実装手順
実際に私が実装していった大まかな流れで記載していきます。
文章的に読みにくい点も多々あると思いますがご了承ください。
全てのエラーをキャッチする 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
を自分のコードにも追加しました。
ドキュメントにあるコードをそのまま持って来ています。
'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 の一覧を表示すると仮定して実装していきます。
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 を発生させます。
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
がエラーをキャッチしてエラーメッセージを表示しました。
カスタムエラーの導入
API から 404
が返ってきた場合には、「リソースが見つかりません。」というタイトルと「指定されたリソースは存在しないか、アクセスできません。」というメッセージを表示したい。
カスタムエラーの定義
まず Error
クラスを拡張した APIError
を作成し、タイトルと詳細を入れる title
と description
を作成します。
そして、その APIError
を拡張する形で特定のエラークラスを作成しました。
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';
}
}
ここでは単純化のため 404
の NotFoundError
だけ定義しましたが実際には他の種類のエラーも定義しています。
error.tsx の修正
先ほど定義した APIError
であれば title
と description
が表示できるため条件分岐を入れて修正してみます。
'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 する
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
の条件判定がうまくいっていないようです。
クラアントコンポーネントからサーバーコンポーネントへの受け渡し
先ほどの挙動から、ServerComponent
で起きたカスタムエラーが ClientComponent
である Error.tsx
に渡るとカスタムエラーではなく Error
クラスと扱われていることがわかりました。
これを防ぐためにClientComponent
から、ServerComponent
へシリアライズしたエラーを渡して、ClientComponent
内で再度カスタムエラーとして Throw する方向で考えます。
カスタムエラーに serialize
とdesirialize
メソッドを持たせる
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
を作成します。
'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
コンポーネントを利用して丁寧にハンドリングできるよう処理を追加します。
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されることエラーページにタイトルと説明を表示することができます。
終わりに
この記事では、Next.js の AppRouter を使用したエラーハンドリングの実装方法について説明しました。カスタムエラーを定義し、それをシリアライズしてクライアントコンポーネントに渡すことで、適切なエラーメッセージを表示する方法を実装しました。
エラーハンドリングはアプリケーションの信頼性を高めるために重要です。特に複雑なアプリケーションでは、予期しないエラーが発生する可能性が高く、それをユーザーに適切に通知することが求められます。今回紹介した方法は、カスタムエラーを使ってより詳細なエラーメッセージを提供することで、ユーザー体験を向上させる一助となります。
この記事がエラーハンドリングの参考になれば幸いです。改善点や新しいアイデアがあれば、ぜひコメントで教えてください。