C
Next.js/基礎/Lesson 03

ファイルコンベンション — loading.tsx · error.tsx · not-found.tsx

30分·theory
このチャプター
3/3
TypeScript

ファイルコンベンション — loading.tsx · error.tsx · not-found.tsx

💡 なぜ学ぶのか? — 「ファイル名 = 動作」のコンベンション

🎯 Pages Router 時代: `` コンポーネントを手動で作成し、各ページにインポートして条件分岐レンダリングを実装する必要がありました。
💼 App Router では: 同じフォルダに `loading.tsx` ファイルを置くだけで完了。Next.js が自動的に `}>` でラップしてくれます。
エラー処理も同様 — `error.tsx` を置くだけで、そのフォルダ配下で発生したエラーを自動的にキャッチし、フォールバック UI を表示します。
🔗 これを知らないと、「なぜローディングが自動で表示されるのか」「エラーがどこでキャッチされているのかわからない」といったデバッグの落とし穴にはまることになります。
📈 ネストされたルートでは、最も近いファイルが適用されます — フォルダツリーがそのまま UI ツリーになります。
🏢 실무에서는
Server Component が `fetch` で await している間、ユーザーは空白の画面を見ることになります。同じフォルダに `loading.tsx` を一つ置くだけで、その間スケルトン UI が自動表示されます。もし fetch が throw した場合は `error.tsx` が自動的にキャッチして「もう一度試す」ボタンを表示します。これがコード追加ゼロ行で実現できます。

5つの特殊ファイル — ファイル名がコンベンション

1. 5つのファイルとその役割

ファイル自動適用される内容コンポーネントの種類
page.tsxURL ルートのページ本体Server または Client
layout.tsxそのフォルダ配下の全ページに共通のレイアウト(状態保持)Server または Client
loading.tsxそのフォルダのページ読み込み中に表示される <Suspense> フォールバックServer または Client
error.tsxそのフォルダ配下でエラーが発生した際の React Error Boundary フォールバックClient のみ ('use client' 必須)
not-found.tsxnotFound() 呼び出し時、またはルートが一致しない場合Server または Client

2. loading.tsx — Suspense の自動ラッピング

tsx
// app/posts/loading.tsx
export default function Loading() {
  return <div className="animate-pulse">読み込み中...</div>;
}
tsx
// app/posts/page.tsx — このコンポーネントが await で待機している間
export default async function PostsPage() {
  const posts = await fetch('https://api/posts').then(r => r.json());
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
// → loading.tsx が自動的にフォールバックとして表示される
//   <Suspense fallback={<Loading />}><PostsPage /></Suspense> と同等

3. error.tsx — 自動 Error Boundary

tsx
// app/posts/error.tsx
'use client'; // ★ エラー境界は Client 必須

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>エラーが発生しました</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>再試行</button>
    </div>
  );
}
  • reset() を呼び出すと Error Boundary がリセットされ、ページの再レンダリングが試みられます。
  • error.digest はサーバー側エラーを識別するID(ロギング用)です。
  • error.tsx同一フォルダの layout.tsx で発生したエラーはキャッチできません — 一つ上の階層のフォルダに配置する必要があります。

4. not-found.tsx — 404 UI

tsx
// app/posts/[id]/page.tsx
import { notFound } from 'next/navigation';

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await db.post.findUnique({ where: { id: Number(params.id) } });
  if (!post) notFound(); // これを呼び出すと not-found.tsx がレンダリングされる
  return <article>{post.title}</article>;
}
tsx
// app/posts/[id]/not-found.tsx
export default function NotFound() {
  return <div>お探しの記事はありません。<Link href="/posts">リストへ</Link></div>;
}

5. ネスト時は最も近いファイルが適用される

code
app/
├── error.tsx          ← グローバルフォールバック
├── posts/
│   ├── error.tsx      ← /posts/* のエラーをキャッチ
│   ├── loading.tsx    ← /posts/* の読み込み時に表示
│   └── [id]/
│       └── error.tsx  ← /posts/[id] に最も近い → このエラーをキャッチ

最も近いファイルが優先されます。同じフォルダにファイルがない場合は、親フォルダへ遡って検索します。

💻 🅰️ Pages Router — 手動で Suspense + Error Boundary を実装
// ❌ Pages Router — コンポーネントを手動で作成 + 条件付きレンダリング

// 📁 components/Loading.tsx
export function Loading() {
  return <div className="animate-pulse">読み込み中...</div>;
}

// 📁 components/ErrorBoundary.tsx
import { Component, type ReactNode } from 'react';

class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
  state = { error: null as Error | null };
  static getDerivedStateFromError(error: Error) { return { error }; }
  render() {
    if (this.state.error) {
      return (
        <div>
          <h2>エラー: {this.state.error.message}</h2>
          <button onClick={() => this.setState({ error: null })}>再試行</button>
        </div>
      );
    }
    return this.props.children;
  }
}
export { ErrorBoundary };

// 📁 pages/posts/index.tsx
import { useState, useEffect } from 'react';
import { Loading } from '../../components/Loading';
import { ErrorBoundary } from '../../components/ErrorBoundary';

export default function PostsPage() {
  const [posts, setPosts] = useState(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    fetch('/api/posts')
      .then(r => { if (!r.ok) throw new Error('失敗'); return r.json(); })
      .then(setPosts)
      .catch(setError);
  }, []);

  if (error) return <div>エラー: {error.message}</div>;
  if (!posts) return <Loading />;
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

// 📁 pages/_error.tsx — グローバルエラー (Pages Router 専用)
function Error({ statusCode }: { statusCode: number }) {
  return <p>{statusCode ? `サーバー ${statusCode} エラー` : 'クライアントエラー'}</p>;
}
Error.getInitialProps = ({ res, err }) => {
  const statusCode = res?.statusCode ?? err?.statusCode ?? 404;
  return { statusCode };
};
export default Error;

// 📁 pages/404.tsx — 404ページ (Pages Router 専用)
export default function Custom404() {
  return <h1>404 — 見つかりません</h1>;
}
💻 🅱️ App Router — ファイルを4つ置くだけで完了
// ✅ App Router — ファイル名だけで自動適用

// 📁 app/posts/page.tsx (Server Component)
interface Post { id: number; title: string; }

export default async function PostsPage() {
  const posts: Post[] = await fetch('https://api/posts', {
    next: { revalidate: 60 },
  }).then(r => {
    if (!r.ok) throw new Error('posts fetch 失敗');
    return r.json();
  });

  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

// 📁 app/posts/loading.tsx — 自動的に Suspense fallback
export default function Loading() {
  return (
    <div className="animate-pulse space-y-2">
      <div className="h-4 bg-gray-200 rounded" />
      <div className="h-4 bg-gray-200 rounded" />
      <div className="h-4 bg-gray-200 rounded w-2/3" />
    </div>
  );
}

// 📁 app/posts/error.tsx — 自動的に Error Boundary
'use client'; // ★ error は常に Client

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>posts を読み込めませんでした</h2>
      <p className="text-gray-600">{error.message}</p>
      {error.digest && <p className="text-xs">エラー ID: {error.digest}</p>}
      <button onClick={() => reset()}>再試行</button>
    </div>
  );
}

// 📁 app/posts/[id]/page.tsx — 特定の記事ページ
import { notFound } from 'next/navigation';

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await db.post.findUnique({ where: { id: Number(params.id) } });
  if (!post) notFound(); // ★ これを呼び出すと not-found.tsx がレンダー
  return <article>{post.title}</article>;
}

// 📁 app/posts/[id]/not-found.tsx — 自動的に 404 UI
import Link from 'next/link';
export default function NotFound() {
  return (
    <div>
      <h2>お探しの記事はありません</h2>
      <Link href="/posts">← リストへ</Link>
    </div>
  );
}

// フォルダーツリー:
// app/posts/
// ├── page.tsx          ← リストページ
// ├── loading.tsx       ← リスト読み込み UI
// ├── error.tsx         ← リストエラー UI
// └── [id]/
//     ├── page.tsx      ← 詳細ページ
//     └── not-found.tsx ← 詳細 404 UI
// 登録・import コード 0行。ファイル名だけで動作。

💡 💡 ファイルコンベンション 実践5か条

1. error.tsx には必ず 'use client' を付ける
React Error Boundary はクライアント専用のため、コンベンションとして強制されます。忘れるとビルドエラーになります。

2. error.tsx は同一フォルダの layout.tsx のエラーをキャッチできない
layout でエラーが throw された場合、一つ上の階層の error.tsx がキャッチします。ルートレイアウトのエラーは global-error.tsx(Client 専用)がキャッチします。

3. loading.tsx は page.tsx の最初の await の間だけ表示される
ページのレンダリングが完了すると消えます。その後のクライアントサイドナビゲーション時には再表示されます。

4. notFound() は throw に似ている — 以降のコードは実行されない

tsx
if (!post) notFound();
console.log(post.title); // ここには到達しない(notFound がフローを終了させる)

TypeScript もここで post を non-null に型ナローイングします。

5. コンベンションファイルはすべて同じフォルダに置く

code
posts/
├── page.tsx
├── layout.tsx
├── loading.tsx
├── error.tsx
└── not-found.tsx

フォルダが UI のステートマシンになります。ページのあらゆる状態が一目で把握できます。

⚡ 実際に試してみよう — ファイルコンベンションのマッピング

URL ごとにどのコンベンションファイルがどの順序で適用されるかをシミュレーションします。
✏️ JS 코드
📟 コンソール出力
▶ 実行ボタンを押してください
⚠️ ブラウザのサンドボックスで実行 — console.log()のみ対応、alert/fetchは不可

確認クイズ

App Router の `error.tsx` が必ず `'use client'` でなければならない理由は何ですか?
💡 React Error Boundary は `componentDidCatch` や `getDerivedStateFromError` といったクラスコンポーネントのライフサイクルメソッドを使用しており、これらは **クライアントサイド React** の機能です。Next.js は `error.tsx` を自動的に Error Boundary でラップするため、そのコンポーネントは必ず Client Component である必要があります。そのため、ファイルの先頭に `'use client'` の記述が強制されます。
ファイル規約 — loading / error / not-found - Next.js