【Next.js】tRPCを用いたRSCでのエラーハンドリング
はじめに
エラーハンドリングに力を入れていますか?
エラーハンドリングは、アプリケーションの安定性と信頼性を保つ上で欠かせない要素です。適切なハンドリングができていないと、ユーザー体験が損なわれるだけでなく、デバッグやメンテナンスの効率も大きく低下します。
今回は、エラーの種類ごとに最適なハンドリング方法を考察し、App RouterやtRPCを使用した具体例を用いて、いくつかのエラーハンドリングの手法を紹介していきます。
技術スタックが異なる場合でも読める内容になっていますので、エラーハンドリングを実装するための参考になれば嬉しいです。
エラーハンドリングの重要性
エラーハンドリングは、アプリケーション開発において以下のようなとても重要な役割を果たします。
-
UI/UXの向上
エラーが適切に処理されないと、ユーザーは予期せぬクラッシュやエラーメッセージに直面します。これにより、ユーザーが混乱し、アプリケーションに対する信頼を失う可能性があります。適切なエラーハンドリングにより、ユーザーに対して分かりやすいフィードバックを提供し、必要に応じて次のステップを案内することができます。 -
デバッグとメンテナンスの容易化
適切なエラーハンドリングにより、開発者は問題を迅速に特定・修正できます。具体的なエラーメッセージやログを活用することで、デバッグやメンテナンスがスムーズに進みます。
エラーの種類とハンドリング方法
エラーの種類を発生場所に応じてクライアントサイドのエラー、サーバーサイドのエラー、API通信中のエラーに分類し、それぞれどのようにハンドリングすべきかを考えてみました。
さらに、これらのエラーを6つのエラーハンドリング方法に当てはめ、後項ではそれぞれの実装例を紹介します。
- ①フォームバリデーション
- ②
error.tsx
、global-error.tsx
- ③
not-found.tsx
- ④Next.jsのmiddleware
- ⑤tRPCのプロシージャ
- ⑥tRPCのmiddleware
クライアントサイドのエラー
-
バリデーションエラー:入力フォームで不正な値が入力された際のエラー
→ ①フォームバリデーション
クライアントサイドやサーバーサイドでフォームの入力内容を検証し、不正なデータが送信されないようにします。これにより、無駄なリクエストを防ぐとともに、ユーザーにすぐにフィードバックを返します。(APIリクエストをする前のハンドリングという点で今回はクライアントサイドのエラーに分類しました。) -
UIエラー:Reactレンダリング中のエラー
→ ②error.tsx、global-error.tsx
UIエラーのような予期せぬエラーは、ユーザーにエラーメッセージを表示したり、再試行の誘導をしたりしてアプリケーション全体がクラッシュしないように気をつける必要があります。 -
404エラー:指定されたリソースやページが見つからない場合のエラー
→ ③not-found.tsx
404エラーは、クライアントがサーバーにリクエストを送信した際、指定されたリソースやページが存在しない場合に発生します。この場合、not-found.tsx
を使ってユーザーに404エラーページを表示し、ユーザーが迷わないように適切なナビゲーションを提供します。ユーザーが別のページやホームにアクセスできるリンクを設け、スムーズに正しい情報にたどり着けるよう誘導することが重要です。
サーバーサイドのエラー
-
認証・認可エラー(リソースアクセス):未認証や権限のないユーザーがページやリソースにアクセスしようとした場合のエラー
→ ④Next.jsのmiddleware
認証・認可エラーは、未認証のユーザーが認証が必要なページにアクセスしようとした場合や、認証済みでもアクセス権を持たないリソースにアクセスしようとした場合に発生します。Next.jsのmiddlewareを使って、リクエストがサーバーに到達する前に認証・認可チェックを一貫して行います。 -
認証・認可エラー(tRPCのエンドポイントアクセス):未認証や権限のないユーザーがtRPCのエンドポイントにアクセスした際のエラー
→ ⑥tRPCのmiddleware
tRPCのmiddlewareは、APIリクエストに対して認証と認可のチェックを行う役割を担います。未認証やアクセス権限のないユーザーからのリクエストをサーバー側で適切に処理し、エラーレスポンスを返すことで、リソースへの不正なアクセスを防ぎます。 -
データベースエラー:データベースへのクエリ実行や接続に失敗した際のエラー
-
サーバー内部エラー:サーバー側の処理中に発生する予期せぬエラー(内部ロジックの不具合など)
→ ⑤tRPCのプロシージャ
tRPCのエンドポイントでデータベースクエリの失敗や接続エラーやサーバー内部エラーが発生した場合、tRPCがそのエラーをキャッチしてクライアントに適切なエラーレスポンスを返します。
クライアント側ではセキュリティ上、詳細なエラーメッセージをユーザーに知らせず、「内部エラーが発生しました。後ほど再試行してください」といった一般的なメッセージを表示します。サーバー内部では、詳細なログを記録することで、エラーの原因を特定し、迅速に対処できるようにします。
API通信中のエラー
- ネットワークエラー:クライアントとサーバー間の通信が失敗した際に発生するエラー
-
タイムアウトエラー:サーバーからの応答が指定された時間内に返ってこなかった際に発生するエラー
→ ⑤tRPCのプロシージャ
ネットワークの接続が失敗した場合やサーバーからの応答が遅れ、タイムアウトが発生した場合、tRPCはその通信エラーをキャッチします。クライアント側では、ネットワークの不具合を検出し、ユーザーに「接続が失敗しました。再試行してください」といったメッセージを表示し、再試行を促します。
エラーハンドリングの実装例
ここからはApp RouterとtRPCを使用した具体例を用いて、いくつかのエラーハンドリングの実装例を紹介していきます。
①フォームバリデーション
ユーザーの入力が不正な場合、APIリクエストを送信する前に詳細なエラーメッセージを表示することで、UI/UXが向上します。クライアントサイドではReact Hook Formなどのライブラリを使用し、サーバーサイドでのエラーハンドリングにはServer Actionsを活用して実装できます。今回は、Server Actionsでのバリデーション方法を紹介します。
Server Actionsとは
Server ActionsはNext.jsのv13で登場し、v14で安定版となった機能です。<form>
タグにaction属性としてServer Actionsを設定することでサーバーサイドで実行される関数をClient Componentsから直接呼び出すことができます。
Server Actionsでのフォーム検証
サーバーサイドではZodを使用してバリデーションを行い、エラーがあればそのエラーをクライアントに返します。クライアント側では、useFormState
を使用してこれらのエラーメッセージを表示します。
以下は、Zodを用いたサーバーサイドでのバリデーションの実装例です。
フォーム送信時にサーバーサイドで実行される関数の定義です。
"use server";
import { z } from "zod";
import { api } from "@/trpc/server";
export type ZodErrors = {
message?: string[] | undefined;
name?: string[] | undefined;
} | null;
export type State = {
errorMessage: ZodErrors;
};
// フォームのスキーマ設定
const schema = z.object({
name: z
.string()
.min(3, { message: "ユーザー名は3文字以上で入力してください" }),
message: z
.string()
.min(3, { message: "メッセージは3文字以上で入力していください" }),
});
export async function clientFormAction(prevState: State, formData: FormData) {
// formData から取得したデータをバリデーション
const validatedFields = schema.safeParse({
name: formData.get("name"),
message: formData.get("message"),
});
if (validatedFields.error) {
const errorMessages = validatedFields.error.flatten().fieldErrors;
return { errorMessage: errorMessages };
}
await api.chat.create({ name: validatedFields.data.message });
return { errorMessage: null };
}
schema.safeParse
を使用して、formDataから取得したデータのバリデーションを実施します。
クライアントサイドでは、ReactのuseFormState
フックを使用して、サーバーサイドから返されたエラーメッセージを表示します。
"use client";
import { clientFormAction, State, ZodErrors } from "@/lib/actions";
import { useFormState } from "react-dom";
const initialState: State = { errorMessage: null };
export function ClientForm() {
const [state, formAction] = useFormState(clientFormAction, initialState);
return (
<div>
<form action={formAction}>
<input type="text" name="name" />
{state?.errorMessage?.name && <p>{state.errorMessage.name}</p>}
<input type="text" name="message" />
{state?.errorMessage?.message && <p>{state.errorMessage.message}</p>}
<button type="submit" defaultValue="">
送信
</button>
</form>
</div>
);
}
useFormState
でエラーメッセージのstateを管理しています。
state.errorMessage
は、サーバーサイドから返されたバリデーションエラーメッセージを格納しています。
ユーザーがフォームを送信すると、サーバーサイドでバリデーションが行われ、エラーがあればそのメッセージがクライアントに返され、フォームに表示されます。
error.tsx
、global-error.tsx
②error.tsx
は予期せぬエラーをキャッチするために使用されます。UIエラーやランタイムエラーなど、適切にハンドリングされなかったエラーを最終的にキャッチし、ユーザーにフォールバックUIを表示する役割を果たします。
error.tsx
の設置方法
error.tsx
を使用するには、ルートセグメント内にerror.tsx
ファイルを追加するだけです。ディレクトリツリーの例は以下の通りです。
app/
├── dashboard/
│ ├── page.tsx
│ ├── layout.tsx
│ ├── templete.tsx
│ └── error.tsx # dashboardセグメント内で発生するエラーをキャッチ
├── layout.tsx
├── templete.tsx
└── page.tsx
このツリー構造では、dashboardセグメント内で発生するエラーをキャッチして、error.tsx
でハンドリングします。
error.tsx
は全てのディレクトリに配置する必要はなく、エラーが発生する可能性があるページにのみ配置すれば十分です。
error.tsx
の実装方法
error.tsx
は内部でReactの Error Boundaryを利用しているため、Client Componentsとして機能します。そのため、ファイルの先頭に"use client"ディレクティブを追加する必要があります。
"use client"
import { useEffect } from 'react'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error(error)
}, [error])
return (
<div>
<h2>エラーが発生しました</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>再実行</button>
</div>
)
}
エラーの原因は一時的なものである場合があるため、もう一度試すと問題が解決する可能性があります。
そこで、reset()
関数は、エラーが発生したコンポーネントを再レンダリングできます。そのため、ユーザーに再試行を促すことができます。
global-error.tsx
の設置方法
error.tsx
の注意点としてはlayout.tsx
の内側の層に位置するため、同階層のlayout.tsx
やtemplate.js
のエラーをキャッチすることはできません。そのため、親セグメントのerror.tsx
でキャッチする必要があります。
ルートレイアウトのエラーをハンドリングするためには別途global-error.tsx
を使用する必要があります。ディレクトリツリーの例は以下の通りです。
app/
├── dashboard/
│ ├── page.tsx
│ ├── layout.tsx
│ ├── templete.tsx
│ └── error.tsx
├── layout.tsx
├── templete.tsx
├── global-error.tsx # ルートレイアウトのエラーをキャッチ
└── page.tsx
error.tsx
とglobal-error.tsx
の設置によるコンポーネントツリーのイメージです。
<RootLayout>
<ErrorBoundary fallback={<GlobalError />}> // 特別なGlobalErrorがルートレイアウトのエラーをキャッチする
...
<ErrorBoundary fallback={<Error />}>
<Layout> // 親セグメントでエラーをキャッチする
<Template> // 親セグメントでエラーをキャッチする
<ErrorBoundary fallback={<Error />}> // Pageのエラーをキャッチ
<Page />
</ErrorBoundary>
</Template>
</Layout>
</ErrorBoundary>
...
</ErrorBoundary>
</RootLayout>
global-error.tsx
は通常のError Boundaryとは異なる方法で実装されているためルートレイアウトのエラーをキャッチすることができます。
内部構造
vercelの出しているAIのv0に聞いてみたところ概念的には以下のようになっているらしい。
try {
<RootLayout>
<ErrorBoundary fallback={<GlobalError />}>
{/* アプリケーションの残りの部分 */}
</ErrorBoundary>
</RootLayout>
} catch (error) {
<GlobalError error={error} />
}
global-error.tsx
の実装方法
この場合、ルートレイアウトがクラッシュし利用できない可能性があるため代わりにglobal-error.tsx
内に必ず<html>
と<body>
タグを含める必要があります。
"use client";
export default function GlobalError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<html>
<body>
<h2>ルートレイアウトでエラーが発生しました</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>もう一度実行する</button>
</body>
</html>
);
}
not-found.tsx
③クライアントがサーバーにリクエストを送信した際、指定されたリソースやページが存在しない場合にはnot-found.tsx
を使用して404エラーページを作成することができます。
not-found.tsx
はNext.jsのApp Routerで使用されるファイルで、特定のページが見つからない場合に表示されるカスタム404エラーページを定義するためのものです。
not-found.tsx
の設置方法
アプリケーションの特定のルートディレクトリに配置します。
app/
├── not-found.tsx
└── page.tsx
not-found.tsx
の実装方法
デフォルトではServer Componentsになります。404エラー時に表示するUIを定義します。
export default function NotFound() {
return (
<div>
<h1>404 - ページが見つかりません</h1>
<p>お探しのページは存在しないか、移動した可能性があります。</p>
</div>
);
}
④Next.jsのmiddleware
middlewareは、クライアントから送信されたリクエストがサーバーのメイン処理に進む前に実行されます。主な用途として、認証エラーや認可エラーのチェック、リクエストの正当性確認などがあります。例えば、ユーザーが適切な認証情報を持っているか、特定のリソースにアクセスする権限があるか、リクエストヘッダーやパラメータが正しい形式であるかなどをチェックします。
middlewareの設置方法
プロジェクトのルートディレクトリにmiddleware.ts
ファイルを設置します。
├── app/
│ ├── page.tsx
│ ├── layout.tsx
│ └── dashboard/
│ ├── page.tsx
│ └── layout.tsx
├── middleware.ts
├── public/
└── ...
middlewareの実装
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
const token = cookies().get("access_token")?.value;
// トークンのチェックなど
...
if (!token) {
// 未ログインでログインが必要なページにアクセス -> ログインページにリダイレクト
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
// matcherでmiddlewareの適用範囲を指定
export const config = {
matcher: ['/dashboard/:path*'], // 例えば、/dashboard配下のすべてのルートで有効
};
一例ですが、cookies
を使用して現在のリクエストに含まれるクッキー情報を取得し、ユーザーがログインしているかを確認します。トークンが存在しない、有効期限が切れているなどの場合(未ログイン)、ログインページにリダイレクトします。
matcherで指定されたパスに対して、ミドルウェアが適用されます。これにより、特定のルートに対してのみミドルウェアを適用することができます。
⑤tRPCのプロシージャ
サーバー内部やAPIにおけるバックエンドとフロントエンド間の通信で発生したエラーを適切にキャッチし、エラーをフロントエンドに伝達する役割があります。
エラーフォーマット
tRPCのプロシージャでエラーが発生すると以下のようなエラープロパティを含むオブジェクトをクライアントに伝達します。この情報をもとにクライアント側はエラーを適切に処理します。
不正なリクエスト入力によってクライアントエラーによりリクエストを処理できない場合に発生したエラー応答の例を次に示します。
{
"id": null,
"error": {
"message": "\"password\" must be at least 4 characters",
"code": -32600,
"data": {
"code": "BAD_REQUEST",
"httpStatus": 400,
"stack": "...",
"path": "user.changepassword"
}
}
}
tRPCの定義するエラーコードとHTTPコード
以下は、tRPCが定義するエラーコードと、それぞれが示すエラーの種類および対応するHTTPコードについての説明です。
コード | 説明 | HTTPコード |
---|---|---|
BAD_REQUEST | クライアントエラーによりリクエストを処理できない場合 | 400 |
UNAUTHORIZED | 認証資格が不足している場合 | 401 |
FORBIDDEN | 必要なデータソースにアクセスできない場合 | 403 |
NOT_FOUND | リソースが見つからない場合 | 404 |
METHOD_NOT_SUPPORTED | リクエストメソッドがサポートされていない場合 | 405 |
TIMEOUT | 未使用の接続がタイムアウトした場合 | 408 |
CONFLICT | リソースの状態が競合している場合 | 409 |
PRECONDITION_FAILED | リソースへのアクセスが拒否された場合 | 412 |
PAYLOAD_TOO_LARGE | リクエストがサーバーの制限を超えている場合 | 413 |
UNSUPPORTED_MEDIA_TYPE | ペイロード形式がサポートされていない場合 | 415 |
UNPROCESSABLE_CONTENT | リクエストが理解されるが処理できない場合 | 422 |
TOO_MANY_REQUESTS | リクエストが過剰に送信された場合 | 429 |
CLIENT_CLOSED_REQUEST | クライアントがリクエストを中断した場合 | 499 |
INTERNAL_SERVER_ERROR | サーバー内部でエラーが発生した場合 | 500 |
NOT_IMPLEMENTED | サーバーが機能をサポートしていない場合 | 501 |
BAD_GATEWAY | 上位サーバーから無効なレスポンスを受け取った場合 | 502 |
SERVICE_UNAVAILABLE | サーバーがリクエストを処理できない場合 | 503 |
GATEWAY_TIMEOUT | 上位サーバーからのレスポンスがタイムアウトした場合 | 504 |
TRPCError
クラスを使用してカスタムエラーを生成
tRPCでは、TRPCError
クラスを使用してカスタムエラーを生成することができます。このクラスを使うことで、エラーコードやメッセージを指定し、クライアントに明確なエラー情報を返すことができます。
tRPCのプロシージャ内でエラーをスロー
export const chatRouter = createTRPCRouter({
hello: protectedProcedure
.input(z.object({ text: z.string() }))
.query(({ input }) => {
if (input.text === "") {
// input.textが空の場合、BAD_REQUESTエラーをスロー
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid text format",
});
}
return {
greeting: `Hello ${input.text}`,
};
}),
});
例えば、input.text
が空の場合、BAD_REQUEST
エラーをスローします。
Server Components内でエラーハンドリング
エラーハンドリング付きのtRPCプロシージャ呼び出し関数を作成します。
import { api } from "@/trpc/server";
import { TRPCError } from "@trpc/server";
~~
~~
// エラーハンドリング付きのtRPCプロシージャ呼び出し関数
const greeting = async (req: {
text: string;
}): Promise<{ greeting: string | null; errorMessage: string | null }> => {
try {
// tRPCのプロシージャを呼び出す
const response = await api.chat.hello(req);
return { greeting: response.greeting, errorMessage: null };
} catch (error) {
console.log(error);
let errorMessage = "不明なエラーが発生しました。";
if (error instanceof TRPCError) {
switch (error.code) {
case "BAD_REQUEST":
errorMessage = "無効なリクエストです。";
break;
case "FORBIDDEN":
errorMessage = "アクセスが拒否されました。";
break;
default:
errorMessage = "予期せぬエラーが発生しました。";
break;
}
}
return { greeting: null, errorMessage };
}
};
エラーはtry-catch
を使用しハンドリングしています。
error instanceof TRPCError
のように型を絞り込み、TRPCError
が発生した場合は、そのエラーコードに基づいて適切なエラーメッセージを設定して返します。
エラーメッセージがあれば表示します。
export default async function Page() {
const result = await greeting({ text: "" });
return (
<div>
<p>Server Components</p>
{result.errorMessage ? (
<p>
エラーが発生しました: <span>{result.errorMessage}</span>
</p>
) : (
<h2>{result.greeting}</h2>
)}
</div>
);
}
Client Components内でエラーハンドリング
Client Componentsでのフェッチでは内部でTanstack Queryを利用しています。
"use client";
import { api } from "@/trpc/react";
export function ClientError() {
const { data, error } = api.chat.hello.useQuery({ text: "" });
// ユーザーに表示するためのエラーメッセージを決定
let errorMessage = "";
if (error) {
switch (error.data?.code) {
case "BAD_REQUEST":
errorMessage = "無効なリクエストです。";
break;
case "FORBIDDEN":
errorMessage = "アクセスが拒否されました。";
break;
default:
errorMessage = "予期せぬエラーが発生しました。";
break;
}
}
return (
<div>
<p>Client Components</p>
<p>{data?.greeting}</p>
{errorMessage && <p>{errorMessage}</p>}
</div>
);
}
Tanstack QueryのuseQuery
は、データのフェッチ、キャッシュ、エラーハンドリングを自動的に行います。そのため、errorプロパティには、リクエストが失敗した際のエラーオブジェクトが格納されます。
ユーザーに表示するためのエラーメッセージを決定してから表示します。
Client Components呼び出しでもサーバーサイドでエラーハンドリングを行いたい場合
クライアントサイドにはエラーが返ってきますが、サーバーサイドでエラーをログに出すなどしたい場合は、Route Handler内でハンドリングすることができます。
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { type NextRequest } from "next/server";
import { appRouter } from "@/server/api/root";
import { createTRPCContext } from "@/server/api/trpc";
const createContext = async (req: NextRequest) => {
return createTRPCContext({
headers: req.headers,
});
};
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext(req),
onError: ({ error, type, path, input, ctx, req }) => {
if (error.code === "BAD_REQUEST") {
// エラーログをレポートする処理など
...
}
},
});
export { handler as GET, handler as POST };
Client Componentsで呼び出す場合、tRPCのプロシージャで発生したすべてのエラーは、クライアントに送信される前にonError
メソッドを通過します。したがって、ここでもエラーハンドリングを行うことができます。
⑥tRPCのmiddleware
tRPCのmiddlewareはプロシージャが呼び出される前に実行されるため、APIリクエストやバックエンドの操作に対して、特定の権限チェックやバリデーションを行うことができます。例えば、データの作成、更新、削除といった重要な操作や、機密情報へのアクセスを制限する場合に、tRPCのmiddlewareは非常に有効です。
adminProcedure
を作成
管理者権限を持つかどうかをチェックするプロシージャを作成します。
t.procedure.use()
メソッドを使用することでプロシージャにmiddlewareを追加することができます。
export const adminProcedure = t.procedure
.use(({ ctx, next }) => {
// 権限チェック
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
});
アクセス制限が必要なAPIエンドポイントのみに適用するためのadminProcedure
を作成します。
ctx.session
のようにコンテキスト情報にアクセスすることができるのでそこから権限チェックを行います。権限がなければUNAUTHORIZED
エラーをスローします。
コンテキストについての説明はこちらの記事で紹介しています。
adminProcedure
を使用しプロシージャを定義する
アクセス制限が必要なAPIエンドポイントのみに作成したadminProcedure
を適用します。権限チェックを分離することで、プロシージャ内では独自のロジックのみに集中することができます。
export const chatRouter = createTRPCRouter({
create: adminProcedure
.input(z.object({ name: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({
data: {
name: input.name,
createdBy: { connect: { id: ctx.session.user.id } },
},
});
}),
});
まとめ
今回はエラーハンドリングについて深く考えてみました。
エラーハンドリングは、単なるエラーメッセージの表示だけでなく、ユーザー体験を向上させたり、デバッグやメンテナンスの効率化にもつながります。効果的なエラーハンドリングを実装することで、開発や運用の質を向上させることができます。
最後までお読みいただきありがとうございました!少しでもお役に立てたら嬉しいです!
イベント告知! Generative AI/LLM Engineer Career Meetup #2
次世代のAI技術を活用し、キャリアアップを目指しているエンジニアの皆さんに向けて、第二回目となる「Generative AI/LLM Engineer Career Meetup」を開催します!🎉
実際の現場でのGoogle CloudやVertex AI/Geminiの活用事例について、第一線で活躍する専門家たちが現場で得られた知見を通じてお話しします。
- Google Cloudを活用している or 活用したい生成AI/LLM分野のエンジニア
- AI/LLM開発界隈のエンジニア同士の繋がりを作りたい方
- AI技術に少しでも興味ある方
ぜひお気軽にご参加ください!
イベント詳細、お申し込みはconnpassから↓↓↓↓↓↓
皆様のご参加をお待ちしております!
Discussion