Next.jsのloading.tsxと<Suspense>で段階的にローディング表示を改善してみた

# はじめに
この記事では、Next.js App Routerにおけるローディング体験を、
- `loading.tsx` を使った**ページ全体のローディング表示**
- `<Suspense>` を使った**部分的・段階的なローディング表示**

の2つの手法で比較しながら、それぞれの使いどころを学びます。
最終的に、以下のように段階的なローディング表示を行います。
![step3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3821695/969ec5f1-364b-452d-9973-9c5b364644f7.gif)

# デモアプリ作成(スキップ可)
ローディングを表示するためのアプリを作成します。
ローディング実装のみ見たい方は 飛ばしてください。
今回はchatGPTを使用してローディング以外の部分を作成してみます。
ダミーの記事一覧表示アプリを作成します。
## プロジェクト作成
🛠️ Next.js プロジェクト作成手順（App Router + TypeScript + Tailwind）

✅ 1. プロジェクトの作成
```
npx create-next-app@latest loading-ux-demo \
  --typescript \
  --app \
  --tailwind \
  --eslint \
  --src-dir \
  --import-alias "@/*"
  ```
これで以下が自動で設定されます：

- TypeScript対応
- `app/`ディレクトリ（App Router構成）
- Tailwind CSS
- ESLint
- `src/` ディレクトリ構成
- `@/` で `src/` をエイリアスとして使える

✅ 2. ディレクトリ構成に合わせてファイルを作成
以下のようにファイルを追加：
```
cd loading-ux-demo

# ページとコンポーネントのディレクトリを作成
mkdir -p src/app/articles
mkdir -p src/components

# ファイルを作成（あとで中身をコピペ）
touch src/app/articles/page.tsx
touch src/components/article-list.tsx
touch src/components/article-card.tsx
```
loading.tsx は Next.js が app/articles/loading.tsx を自動認識するので、後で追加してもOKです。

✅ 3. Tailwind のアニメーションを使う準備（特別な設定は不要）
Tailwind CSS は `create-next-app` の時点で導入されているので、
`skeleton` 用の `animate-pulse` や `bg-gray-200` はすぐに使えます。

✅ 4. 動作確認コマンド
```
npm run dev
```
そしてブラウザで http://localhost:3000/articles にアクセス！

<details><summary>ディレクトリ構成</summary>

```
app/
├── articles/
│   ├── page.tsx          ← 遅延付きの記事一覧ページ
├── components/
│   ├── article-list.tsx  ← 記事一覧コンテナ
│   ├── article-card.tsx  ← 遅延付きの 1 記事表示
```
</details>
以下、各コンポーネントの実装。ローディングを挟むために、ところどころ擬似的な重い処理を入れています。
<details>
<summary>ホーム画面の実装（app/articles/page.tsx）</summary>

```tsx
import ArticleList from "@/components/article-list";

export default async function ArticlesPage() {
  //* 重いローディング処理　*/
  await new Promise((res) => setTimeout(res, 1500));
  return (
    <main className="p-6">
      <h1 className="text-2xl font-bold mb-4">記事一覧</h1>
      <ArticleList />
    </main>
  );
}
```
</details>
<details>
<summary>記事一覧画面の実装１（app/articles/article-list.tsx）</summary>

```tsx
import ArticleCard from "./article-card";

const articleIds = [1, 2, 3];

export default function ArticleList() {
  return (
    <div className="space-y-4">
      {articleIds.map((id) => (
        <ArticleCard key={id} id={id} />
      ))}
    </div>
  );
}
```
</details>

<details>
<summary>記事一覧画面の実装２（app/articles/article-card.tsx）</summary>

```tsx
export type Props = {
  id: number;
};

async function fetchArticle(id: number) {
  //* 重いローディング処理　*/
  await new Promise((res) => setTimeout(res, 1000 + Math.random() * 2000));
  return {
    title: `記事 ${id}`,
    author: ["佐藤", "田中", "鈴木"][id % 3],
  };
}

export default async function ArticleCard({ id }: Props) {
  const article = await fetchArticle(id);

  return (
    <div className="border p-4 rounded shadow">
      <h2 className="text-lg font-semibold">{article.title}</h2>
      <p className="text-sm text-gray-500">by {article.author}</p>
    </div>
  );
}
```
</details>

# ローディングの実装
ローディングの実装をステップに分けて行います。
これにより、どのタイミングでどのローディングコンポーネントが表示されるのか分かりやすくなります。
## Step 1（ローディングなしの初期状態）

まず、ローディング表示を何も実装していない**初期状態**です。

この状態では、すべての `Promise` が解決されてから**一気にページ全体が描画される**ため、**読み込み中であることがユーザーに伝わりません**。

特に通信環境が遅い場合、「無反応な画面」のまましばらく待たされることになり、UXとしてはやや不親切です。


![step1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3821695/5ddb983f-868f-45f4-a54b-787f185f8602.gif)

<details><summary>ホーム画面</summary>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3821695/5b5414d7-6931-4cee-88c8-88c57c2ba8ed.png)
</details>

<details><summary>記事一覧画面</summary>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3821695/8f5a1fdf-1404-4ed7-8fec-1b480d5a785d.png)
</details>

## Step 2（ローディング画面の一括表示）

次に、`loading.tsx` を配置した場合です。  
Next.js は `loading.tsx` を自動的に認識し、**同階層にある `page.tsx` の読み込みがサスペンドされたときに表示**されます。

`loading.tsx` を用意することで、**ページ読み込み中の「待ち時間」** を明示でき、簡単にローディングUIを挟むことができます。

具体的には、`page.tsx` が `async` 関数であり、内部で `await` による非同期処理（例：データフェッチ）を含む場合、その遅延に応じて `loading.tsx` が表示されます。


![step2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3821695/4c3b4cd3-8066-4a9b-bdd3-b8fbb2ee8459.gif)

以下のファイルを新規作成
<details>
<summary>app/articles/loading.tsx</summary>

```diff
+ export default function Loading() {
+   return (
+     <div className="min-h-screen flex flex-col items-center justify-center text-center p-6">
+       <div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-400 border-t-transparent mb-6" />
+       <p className="text-gray-600 text-lg">記事一覧を読み込んでいます...</p>
+     </div>
+   );
+ }
```
</details>

> 💡ローディング表示の内訳
> 以下の処理を待つ間、`loading.tsx`が表示さています
> `page.tsx`冒頭
> ```tsx
> //* 重いローディング処理　*/
> await new Promise((res) => setTimeout(res, 1500));
> ```
> `fetchArticle()`冒頭
>```tsx
>//* 重いローディング処理　*/
>await new Promise((res) => setTimeout(res, 1000 + Math.random() * 2000));
>```

## step3（段階的なローディング表示）
`<Suspense>` タグを使用することで、段階的なローディング表示が可能になります。
- `<ArticleCard>` を `<Suspense>` でラップすることで、内部で await が発生した場合、指定した fallback（ここでは Skeleton）が一時的に表示されます
- Skeleton が表示されると、`page.tsx` 内のすべての Promise が解決されたとみなされ、`loading.tsx` のローディング画面が解除されます
- この時点で `<ArticleCard>` 自体はまだ読み込み中でも、代わりに Skeleton が表示されることで、ページ全体の遷移が早く見えるようになります
- なお、`<ArticleCard>` はランダムなローディング時間を伴うため、読み込みが完了した記事から順に表示されるという、より自然でスムーズな体験が実現されます

![step3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3821695/3b08827d-20ae-49db-b158-3baa89bfe6a9.gif)


<details> <summary>差分（Step2 → Step3）</summary>

```diff
// components/article-list.tsx の変更

export default function ArticleList() {
  return (
    <div className="space-y-4">
-      {articleIds.map((id) => (
-        <ArticleCard key={id} id={id} />
-      ))}
+       {articleIds.map((id) => (
+         <Suspense key={id} fallback={<Skeleton />}>
+           <ArticleCard id={id} />
+         </Suspense>
+       ))}
   </div>
  );
}
+ function Skeleton() {
+  return <div className="animate-pulse bg-gray-200 rounded h-20 w-full" />;
+ }
```
</details>

> 💡ローディング表示の内訳
> page.tsx冒頭の以下の処理を待つ間、`loading.tsx` が表示さています
> ```tsx
> //* 重いローディング処理　*/
> await new Promise((res) => setTimeout(res, 1500));
> ```
>そして、`fetchArticle`冒頭の以下の処理を待つ間に、Skeltonが表示されています
>```tsx
>//* 重いローディング処理　*/
>await new Promise((res) => setTimeout(res, 1000 + Math.random() * 2000));
>```

### 💡 レイアウトシフトを防ぐための工夫

段階的に描画されることでUXは向上しますが、ローディング中のスケルトンと表示後のレイアウトが大きく異なると、**「ガタつき（レイアウトシフト）」が起きてしまいUXを損なう**可能性があります。

そのため、`Skeleton` コンポーネントは最終的な `ArticleCard` に近いサイズ・構成にするように工夫しています。

```tsx
function Skeleton() {
  return (
    <div className="animate-pulse bg-gray-200 rounded h-20 w-full" />
  );
}
```

### 🛠 開発中にローディング表示をしっかり確認したいときは？

Chrome の DevTools を使えば、ネットワーク速度を意図的に遅くしてローディングUIの挙動を確認できます。

#### 手順：
1. Chromeで対象ページ（例：`/articles`）を開く
2. DevTools を開き、`Network` タブへ
3. 上部の `No throttling` を `Slow 4G` や `3G` に変更
4. ページをリロードして、**ローディング表示のタイミングやアニメーションの流れを確認**

> 💡 実際のユーザーは通信状況がさまざまなので、**早い環境だけで開発・確認しない**のはとても大事です！

# まとめ

この記事では、Next.js App Routerにおけるローディング表示の実装を3段階に分けて比較しながら紹介しました。

| Step | 特徴                             | ローディング表示 | 描画タイミング | 対応技術          |
|------|----------------------------------|------------------|----------------|-------------------|
| 1    | ローディング未実装               | ❌ なし           | 一括表示       | なし              |
| 2    | `loading.tsx` による画面全体の表示 | ✅ 画面中央        | 一括表示       | `loading.tsx` + `async page.tsx` |
| 3    | `<Suspense>` による部分的ローディング | ✅ スケルトン表示    | 順次表示       | `<Suspense>` + `fallback` |

それぞれのステップで得られた学び：

- **`loading.tsx` の活用で「待っていること」が明示できるようになる**
- **`<Suspense>` を使えば、完了した部分から順に表示され、待たせない体験を実現できる**
- **スケルトンUIのサイズ設計を工夫することで、レイアウトシフトを防げる**
- **Chrome DevTools でネットワーク速度を落とすことで、ローディング挙動の確認がしやすい**

UXを意識したフロントエンド開発では、「表示が遅い＝悪い」ではなく、**「ユーザーにとって自然で安心できる表示」にすることが重要**だと改めて感じました。

## 今回作成したコードはこちら
https://github.com/Miyamoto-tryk/loading-ux-demo