10.

Next.js のエラー処理|error.js と notFound の使い方

編集

この記事の要点

  • エラーは想定内想定外に分けて考える。前者は戻り値、後者は例外で表現する。
  • 入力の検証エラーを throw すると画面ごと差し替わる。useActionState で受け取ってメッセージとして出す。
  • error.tsx は区画ごとに置ける。壊れた部分だけを差し替え、周囲は生かしたまま retry で復帰できる。
  • エラー境界は描画中の例外だけを捕まえる。クリック処理や非同期処理の失敗は自前で捕まえる。
  • 「データが無い」は障害ではない。notFound() を使って 404 として表現する。

エラー処理の設計は、フレームワークの機能を並べるだけでは決まりません。まず「その失敗は起きて当然か、起きてはいけないか」を分けることが出発点になります。App Router はこの 2 つに別々の受け皿を用意しており、取り違えると使い勝手が大きく損なわれます。

12 種類のエラー

想定内のエラー想定外の例外
入力値の検証失敗、外部 API が 400 を返した、在庫切れDB 接続断、null 参照、想定していない型
表現方法戻り値として返す例外を投げる
受け取る側useActionState などの状態error.tsx のエラー境界
ユーザーへの見せ方フォームの脇にメッセージその区画を代替 UI に差し替え

この区別が守られていないと「メールアドレスの形式が違うだけで画面全体が『問題が発生しました』になる」という体験になります。ユーザーが直せるものは画面を壊さずに伝える、というのが基本方針です。

2想定内のエラーは返す

Server Function の中では、検証に失敗しても例外を投げず、メッセージを戻り値にします。

// app/actions.ts
'use server'

export async function createPost(prevState: any, formData: FormData) {
  const title = formData.get('title')
  const content = formData.get('content')

  const res = await fetch('https://api.example.com/posts', {
    method: 'POST',
    body: JSON.stringify({ title, content }),
  })

  if (!res.ok) {
    return { message: '投稿の作成に失敗しました' }
  }
}

受け取る側は useActionState です。返した値がそのまま state に入ります。

'use client'

import { useActionState } from 'react'
import { createPost } from '@/app/actions'

const initialState = { message: '' }

export function Form() {
  const [state, formAction, pending] = useActionState(createPost, initialState)

  return (
    <form action={formAction}>
      <input type="text" name="title" required />
      <textarea name="content" required />
      {state?.message && <p aria-live="polite">{state.message}</p>}
      <button disabled={pending}>作成</button>
    </form>
  )
}

aria-live を付けておくと、スクリーンリーダーが変化を読み上げます。エラーメッセージは視覚以外にも届く必要があります。

サーバーコンポーネントでの取得に失敗した場合も同様で、条件分岐で代替表示を返すか、redirect するのが素直です。

3想定外の例外はエラー境界へ

バグや障害に由来する例外は、投げてエラー境界に任せます。error.tsx を置いた区画が受け皿になり、そこから内側だけが代替 UI に置き換わります。

// app/dashboard/error.tsx
'use client'   // エラー境界は必ずクライアントコンポーネント

import { useEffect } from 'react'

export default function ErrorPage({
  error,
  retry,
}: {
  error: Error & { digest?: string }
  retry: () => void
}) {
  useEffect(() => {
    console.error(error)   // 監視サービスへ送るならここ
  }, [error])

  return (
    <div>
      <h2>問題が発生しました</h2>
      <button onClick={() => retry()}>再試行</button>
    </div>
  )
}

retry を呼ぶと、その区画の取得と描画をやり直します。一時的な通信エラーであれば、ページ全体を再読み込みせずに復帰できます。

error オブジェクトの digest は、本番環境で詳細メッセージが隠されるときにサーバー側ログと突き合わせるための識別子です。画面には「問題が発生しました(コード: xxxx)」のように出しておくと、問い合わせ対応が楽になります。

境界を置く位置で「壊れる範囲」が決まる app/layout.tsx(ヘッダー・ナビ)— 生き残る app/dashboard/error.tsx あり ここで例外が出ると この枠だけが代替 UI に error.tsx を置かない区画 例外は親へ伝播する → より広い範囲が差し替わる
失敗の影響を閉じ込めたい単位に error.tsx を置く。置かなければ、最も近い親の境界まで登っていく。

4コンポーネント単位の境界

ファイル規約はルートの区画にしか置けません。「サイドバーのウィジェットだけを守りたい」といった細かい単位には catchError を使います。

// app/custom-error-boundary.tsx
'use client'

import { catchError, type ErrorInfo } from 'next/error'

function ErrorFallback(props: { title: string }, { error, retry }: ErrorInfo) {
  return (
    <div>
      <h2>{props.title}</h2>
      <p>{error.message}</p>
      <button onClick={() => retry()}>再試行</button>
    </div>
  )
}

export default catchError(ErrorFallback)

返ってきたコンポーネントは、任意の場所でラッパーとして使えます。

import ErrorBoundary from './custom-error-boundary'

export default function Component({ children }: { children: React.ReactNode }) {
  return <ErrorBoundary title="ダッシュボードのエラー">{children}</ErrorBoundary>
}

5ルートレイアウトが壊れたとき

ルートレイアウト自体で例外が出ると、通常のエラー境界では受け止められません。このために app/global-error.tsx があります。ルートレイアウトを置き換える形で表示されるため、自分で htmlbody を書く必要があります

// app/global-error.tsx
'use client'

export default function GlobalError({
  error,
  retry,
}: {
  error: Error & { digest?: string }
  retry: () => void
}) {
  return (
    <html>
      <body>
        <h2>問題が発生しました</h2>
        <button onClick={() => retry()}>再試行</button>
      </body>
    </html>
  )
}

ここが表示される状況はアプリ全体が機能していない状態なので、凝った UI よりも「再読み込み」と「連絡先」が示せれば十分です。

6エラー境界が捕まえないもの

エラー境界は描画中の例外を対象とします。クリックハンドラの中や、描画後に走る非同期処理での失敗は対象外です。

'use client'

import { useState } from 'react'

export function Button() {
  const [error, setError] = useState(null)

  const handleClick = () => {
    try {
      // 失敗するかもしれない処理
    } catch (reason) {
      setError(reason)
    }
  }

  if (error) {
    // 代替 UI を描画する
  }

  return <button type="button" onClick={handleClick}>実行</button>
}

例外は useState に保持し、表示を切り替える形にします。ただし useTransitionstartTransition 内で発生した未処理の例外は、最も近いエラー境界まで伝播します。

7404 は例外ではない

「指定された ID の記事が存在しない」は障害ではありません。notFound() を呼べば、最も近い not-found.tsx が表示されます。

import { notFound } from 'next/navigation'

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPostBySlug(slug)

  if (!post) {
    notFound()
  }

  return <div>{post.title}</div>
}

これをエラー境界に落とすと、サーバー側では 500 系として扱われ、検索エンジンにも意図しない信号を送ることになります。SEO の観点でも、存在しないページは 404 として返すのが正しい振る舞いです。

8つまずきやすいところ

  • error.tsx が効かない'use client' の付け忘れが最頻出です。
  • 入力エラーで画面が吹き飛ぶ:想定内のエラーを throw しています。戻り値に変えます。
  • 本番でエラー内容が出ない:情報漏えいを防ぐため、本番では詳細が伏せられます。digest を頼りにサーバーログを確認します。
  • redirecttry / catch で囲むredirect は制御用の例外を投げるため、包むと遷移が止まります。catch の外に出してください。

画面側の作りは一通り揃いました。次は、外部から呼ばれる API を用意する方法に進みます。

編集
Post Share
子ページ

子ページはありません

同階層のページ
  1. 開発環境の構築とプロジェクトの初期構成
  2. App Router のルーティングとファイル規約
  3. レイアウトと特殊ファイルの使い分け
  4. サーバーコンポーネントとクライアントコンポーネント
  5. サーバー側でのデータ取得と並列化
  6. ストリーミングで体感速度を上げる
  7. キャッシュの仕組みと use cache
  8. 再検証の使い分け
  9. フォーム送信とデータ更新の実装
  10. エラーハンドリングとエラー境界
  11. Route Handlers で API を作る
  12. メタデータ API で SEO を整える
  13. 画像とフォントの最適化
  14. 本番ビルドとセルフホスト
  15. バージョン16 への移行と注意点