3
3

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 の Server Functions で例外を投げた際、メッセージはクライアントに伝わらない

Posted at

Next.js では、Server Functions を用いることで、クライアントから普通の関数を呼び出すような感覚でサーバーで行う処理を呼び出すことができる。
しかし、このような Server Functions から例外を投げた際、クライアント側に渡される情報には制限があることがわかった。
具体的には、例外を表すオブジェクトのクラスは伝わらず、メッセージも (伝わってしまうこともあるが) 基本的には伝わらない。

本記事では、実際に Server Functions で投げた例外のメッセージがクライアントに伝わらない例を確認し、Server Functions からクライアントにエラーメッセージを伝えるかわりの方法を提案する。

事象の再現

今回は、Next.js 15.4.4 を用いて実験を行った。

プロジェクトの作成

create-next-app を用い、以下の設定でプロジェクトを作成する。

質問 答え
What is your project named? test
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? No
Would you like to use `src/` directory? Yes
Would you like to use App Router? (recommended) Yes
Would you like to use Turbopack for `next dev`? Yes
Would you like to customize the default import alias (@/*)? No

ソースファイルの作成

作成したプロジェクトのディレクトリにある public ディレクトリ、およびその中身を削除する。
また、src/app ディレクトリの中身を全て削除し、以下の3ファイルを同ディレクトリに置く。

layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Error in Server Functions test",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja">
      <body>
        {children}
      </body>
    </html>
  );
}
page.tsx
"use client";

import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { sampleAction } from "./action";

export default function Home() {
  const [running, setRunning] = useState(false);
  const [data, setData] = useState("");
  const [result, setResult] = useState("");
  const [isError, setIsError] = useState(false);

  const dataChangeHandler = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    setData(event.target.value);
  }, []);

  const performAction = useCallback((event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (running) return;
    setRunning(true);
    sampleAction(data).then((result) => {
      setResult(result);
      setIsError(false);
    }, (error: unknown) => {
       console.error(error);
       if (error instanceof Error) {
         setResult(error.message);
       } else {
         setResult("" + error);
       }
       setIsError(true);
    }).finally(() => {
      setRunning(false);
    });
  }, [running, data]);

  return (
    <>
      <p>
        整数を入力してください
      </p>
      <form onSubmit={performAction}>
        <p>
          <input type="text" value={data} onChange={dataChangeHandler} disabled={running} />
          <input type="submit" value="送信" disabled={running} />
        </p>
      </form>
      <p style={isError ? { color: "red"} : undefined}>
        {result}
      </p>
    </>
  );
}
action.ts
"use server";

export async function sampleAction(data: string): Promise<string> {
  if (data === "") throw new Error("入力が空です。");
  const dataInt = parseInt(data, 10);
  if (isNaN(dataInt)) throw new Error("入力が整数でないようです。");
  if (dataInt >= Number.MAX_SAFE_INTEGER) throw new Error("入力が大きすぎます。");
  if (dataInt < Number.MIN_SAFE_INTEGER) throw new Error("入力が小さすぎます。");
  return `${dataInt} に 1 を足すと、${dataInt + 1} になります。`;
}

実行 (動いてしまう)

作成したプロジェクトのディレクトリに移動し、以下のコマンドを実行する。

npx next

続いて、Webブラウザで http://localhost:3000/ にアクセスする。
すると、以下の画面になる。(十数秒程度かかることがある)

初期画面

今回のサンプルは、絶対値が十分小さい整数を表す文字列を入力すると、それに 1 を足した数を含む文を出力する。

正常動作の例

また、整数を表さない文字列を入力すると、エラーメッセージを出力する。

エラーメッセージを出力する例

実行 (動かない)

作成したプロジェクトのディレクトリに移動し、以下のコマンドを実行する。

npx next build
npx next start

そして、Webブラウザで http://localhost:3000/ にアクセスする。
すると、先ほどと同じような画面になる。
しかし、整数を表さない文字列を入力して送信すると、以下のように用意したエラーメッセージではなく別のメッセージが出力されてしまう。

エラーメッセージが出力されない

An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.

役立たない方法

代わりのメッセージに「specific message is omitted」とあることから、メッセージではなく例外オブジェクトの種類でエラーの内容を伝えればいいのでは……と考えるかもしれない。
しかし、やってみると、(npx next で実行したときですら) 投げた例外オブジェクトの種類にかかわらず Error として伝わってしまい、うまくいかなかった。

どうすればいいか

例外ではなく、「処理の成否」と「成功した場合は結果、失敗した場合はエラーメッセージ」を格納したオブジェクトを戻り値で返すことで、npx next build および npx next start を用いた場合でも、エラーメッセージをWebブラウザの画面に出力することができた。

クライアント側では、返されたオブジェクトがエラーを表していたら例外を投げることで、処理を共通化できることがある。

page.tsx (performAction 以外は前のコードと同じなので省略)
  const performAction = useCallback((event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (running) return;
    setRunning(true);
    sampleAction(data).then((result) => {
      if (!result.success) throw new Error(result.error);
      setResult(result.result);
      setIsError(false);
    }).catch((error: unknown) => {
       console.error(error);
       if (error instanceof Error) {
         setResult(error.message);
       } else {
         setResult("" + error);
       }
       setIsError(true);
    }).finally(() => {
      setRunning(false);
    });
  }, [running, data]);

サーバー側では、例外を catch して結果オブジェクトに変換することで、最小限のコードの変更で結果オブジェクトを返す方式に変換できる。

action.ts
"use server";

export type SampleActionResult = {
  success: true;
  result: string;
} | {
  success: false;
  error: string;
}

export async function sampleAction(data: string): Promise<SampleActionResult> {
  try {
    if (data === "") throw new Error("入力が空です。");
    const dataInt = parseInt(data, 10);
    if (isNaN(dataInt)) throw new Error("入力が整数でないようです。");
    if (dataInt >= Number.MAX_SAFE_INTEGER) throw new Error("入力が大きすぎます 。");
    if (dataInt < Number.MIN_SAFE_INTEGER) throw new Error("入力が小さすぎます。");
    return {
      success: true,
      result: `${dataInt} に 1 を足すと、${dataInt + 1} になります。`,
    };
  } catch (error) {
    return {
      success: false,
      error: error instanceof Error ? error.message : "" + error,
    };
  }
}

ただし、これではかわりに出力されたメッセージが示唆していた「情報の流出を防ぐ」ことができなくなる。
また、例外を投げた場合は自動でサーバーの標準出力に記録されるが、結果オブジェクトを返した場合は記録されない。
そのため、このように全体を try-catch で囲むのではなく、

  • 仕様上例外が発生しうることを想定している部分だけで try-catch を用いて結果オブジェクトに変換する
  • 明示的なチェックで弾く場合は、例外を投げず、直接結果オブジェクトを返す

ようにしたほうが、

  • 予期したエラーについては、エラーメッセージを返す
  • 予期しないエラーについては、例外をサーバに記録し、デバッグの手助けにする

ことに繋がり、よいだろう。

おまけ:Next.js 14 での挙動とドキュメント

Next.js 14.2.30 で実験を行っても、同様に npx next buildnpx next start で実行し、例外を投げると、エラーメッセージのかわりに

An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.

が出力される結果となった。

ここで、Next.js 14 のドキュメント
Data Fetching: Server Actions and Mutations | Next.js
を見てみると、

We recommend using try/catch to return errors to be handled by your UI.

For example, your Server Action might handle errors from creating a new item by returning a message:

app/actions.ts
'use server'

export async function createTodo(prevState: any, formData: FormData) {
  try {
    // Mutate data
  } catch (e) {
    throw new Error('Failed to create task')
  }
}

と書かれており、例外を用いて UI 側にエラーを返すのを推奨しているかのように思える記述となっている。
しかし、今回の実験でわかったように、Server Action で例外を投げてもエラーメッセージを伝えるのは難しそうであり、これはよくない方法であると考えられる。

とはいえ、よく読むと、UI 側に例外を投げているのはあくまで処理の一例であり、実際に推奨 (recommend) しているのは

using try/catch to return errors to be handled by your UI

である。
今回の記事の「どうすればいいか」で紹介した方法も、UI 用のエラーを返すために try-catch を用いており、これに該当すると考えられる。

なお、執筆時点において、Next.js 15 のドキュメントではこのような UI 側に例外を投げることを推奨しているかのように思える記述は見当たらなかった。

まとめ

Next.js の Server Functions で例外を投げた際、例外のオブジェクトのクラスやメッセージはクライアントに伝わらない。
エラーの種類やエラーメッセージをクライアントに伝えたい場合は、例外を投げるのではなく、エラーを表すオブジェクトを戻り値として返すことで実現できる。
とはいえ、これをやると一部で嫌われる「HTTPのステータスコードは 200 で、ボディを見て初めて失敗していることがわかる」になってしまうんだよなあ……

以前、npx next だけで動作確認をしていると、様々な間違いの見逃しに繋がるなどの害があって良くない、という記事を書いた。
npx next は罠!非推奨! #TypeScript - Qiita
今回、新たに「本来は伝わらないはずの Server Functions で発生した例外のエラーメッセージが、クライアントに伝わってしまう」という罠を発見した。
やはり npx next は使わない、もしくはどうしても手抜きをしたいときだけ使うのがいいかもしれない。

3
3
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
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?