[Next.js]Next.jsではtry-catchしない方が無難
はじめに
ReactとNext.jsは、フレームワーク内部でthrowを活用しています。例えば、以下のようなケースが該当します。
- Suspense は Promise を throw することで実現されている
- RSCで使える
notFound()
はエラーを throw する関数である
そのため、安易にtry-catchを使うとフレームワークの機能を意図せずキャッチしてしまい、期待通りの動作にならないことがあります。
本記事では、Next.jsのRSC(React Server Components)でtry-catchを避けるべき理由と、その代替手段について解説します。
対象読者
- それなりの規模で開発している開発者
- Next.jsを利用している
try-catchが問題を引き起こすケース
SuspenseとPromiseのthrow
ReactのSuspenseは、Promiseをthrowすることでデータの読み込みを待機する仕組みを提供します。例えば、以下のようなコードを考えます。
async function fetchData() {
return new Promise<string>((resolve) => {
setTimeout(() => resolve("データ取得完了"), 1000);
});
}
function SuspendedComponent() {
throw fetchData(); // ここでPromiseをthrow
}
export default function Page() {
return (
<Suspense fallback={<p>Loading...</p>}>
<SuspendedComponent />
</Suspense>
);
}
このコードでは、Suspenseがthrow fetchData()
をキャッチし、データが解決されるまでfallbackを表示します。
しかし、もしtry-catchでこれをキャッチしてしまうと、Suspenseの仕組みが機能しなくなり、意図しない動作につながります。
function SuspendedComponent() {
try {
throw fetchData();
} catch (e) {
console.error(e);
return <p>エラーが発生しました</p>;
}
}
この場合、fetchData()のPromiseがキャッチされてしまい、Suspenseの制御が失われます。
気を付けていてもライブラリが裏側でpromiseをthrowしている可能性もあります。try-catchしない方が無難です。
notFound()のthrow
Next.jsのnotFound()関数は、エラーをthrowすることで404ページへ遷移させる仕組みになっています。
import { notFound } from "next/navigation";
async function fetchPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`);
if (!res.ok) {
notFound(); // ここでエラーをthrow
}
return res.json();
}
しかし、これをtry-catchでキャッチしてしまうと、ページ遷移が発生せず、不適切なエラーハンドリングになってしまいます。
async function fetchPost(id: string) {
try {
const res = await fetch(`https://api.example.com/posts/${id}`);
if (!res.ok) {
notFound(); // try-catchにより意図せずキャッチされる
}
return res.json();
} catch (e) {
console.error("エラーが発生しました", e);
return null; // ここでnullを返してしまうと、意図しない挙動になる
}
}
これも関数の奥でnotFound()を呼んでいる場合、気づくのが難しいので初めからtry-catchを避けた方が無難です。
try-catchの代替手段
エラーをreturnする
フレームワークのthrowを回避するためには、エラーをキャッチするのではなく、適切な戻り値として返す方法が考えられます。
type FetchResult<T> = { data: T | null; error?: Error };
async function fetchPost(id: string): Promise<FetchResult<Post>> {
try {
const res = await fetch(`https://api.example.com/posts/${id}`);
if (!res.ok) {
return { data: null, error: new Error("Not Found") };
}
return { data: await res.json() };
} catch (e) {
if (e instanceof Error) {
return { data: null, error: e };
}
throw e; // Error型じゃない場合はPromiseの場合があるのでre-throw
}
}
このようにすることで、最低限のtry-catchのみでエラーハンドリングが可能になります。
Result型の導入
エラーを明示的に扱いたい場合は、Result型を導入するのも有効です。
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
async function fetchPost(id: string): Promise<Result<Post, string>> {
try {
const res = await fetch(`https://api.example.com/posts/${id}`);
if (!res.ok) {
return { ok: false, error: new Error("Not Found") };
}
return { ok: true, value: await res.json() };
} catch (e) {
if (e instanceof Error) {
return { ok: false, error: e };
}
throw e; // Error型じゃない場合はPromiseの場合があるのでre-throw
}
呼び出し側では、okの値をチェックして適切に処理できます。
const result = await fetchPost("123");
if (!result.ok) {
console.error(result.error);
} else {
console.log(result.value);
}
まとめ
Next.jsのRSCでは、try-catchを安易に使うと、SuspenseやnotFound()のようなフレームワークのthrowを意図せずキャッチしてしまい、予期しない動作を引き起こす可能性があります。
そのため、
- try-catchの使用を最小限に抑える
- エラーはreturnで返すようにする
- Result型を導入してエラーハンドリングを明示的にする
といった方針を取ることで、より安全なNext.jsアプリケーションを開発できます。
try-catchの範囲を最低限にとどめることで、フレームワークの機能を最大限に活用し、意図しないエラーを防ぎましょう。
Discussion
notFound()
などに関しては、unstable_rethrow
というAPIを使えばtry catchしつつ機能を壊さないように実装することが可能です。一応unstableですがシンプルなAPIですし、個人的には必要に応じてこのAPIで対処しようと考えてます。
ご存知でしたら失礼しました、ご参考までに。