はじめに
この記事では、Next.js App Routerにおけるローディング体験を、
-
loading.tsx
を使ったページ全体のローディング表示 -
<Suspense>
を使った部分的・段階的なローディング表示
の2つの手法で比較しながら、それぞれの使いどころを学びます。
最終的に、以下のように段階的なローディング表示を行います。
デモアプリ作成(スキップ可)
ローディングを表示するためのアプリを作成します。
ローディング実装のみ見たい方は 飛ばしてください。
今回は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 にアクセス!
ディレクトリ構成
app/
├── articles/
│ ├── page.tsx ← 遅延付きの記事一覧ページ
├── components/
│ ├── article-list.tsx ← 記事一覧コンテナ
│ ├── article-card.tsx ← 遅延付きの 1 記事表示
ホーム画面の実装(app/articles/page.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>
);
}
記事一覧画面の実装1(app/articles/article-list.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>
);
}
記事一覧画面の実装2(app/articles/article-card.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>
);
}
ローディングの実装
ローディングの実装をステップに分けて行います。
これにより、どのタイミングでどのローディングコンポーネントが表示されるのか分かりやすくなります。
Step 1(ローディングなしの初期状態)
まず、ローディング表示を何も実装していない初期状態です。
この状態では、すべての Promise
が解決されてから一気にページ全体が描画されるため、読み込み中であることがユーザーに伝わりません。
特に通信環境が遅い場合、「無反応な画面」のまましばらく待たされることになり、UXとしてはやや不親切です。
Step 2(ローディング画面の一括表示)
次に、loading.tsx
を配置した場合です。
Next.js は loading.tsx
を自動的に認識し、同階層にある page.tsx
の読み込みがサスペンドされたときに表示されます。
loading.tsx
を用意することで、ページ読み込み中の「待ち時間」 を明示でき、簡単にローディングUIを挟むことができます。
具体的には、page.tsx
が async
関数であり、内部で await
による非同期処理(例:データフェッチ)を含む場合、その遅延に応じて loading.tsx
が表示されます。
以下のファイルを新規作成
app/articles/loading.tsx
+ 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>
+ );
+ }
💡ローディング表示の内訳
以下の処理を待つ間、loading.tsx
が表示さています
page.tsx
冒頭//* 重いローディング処理 */ await new Promise((res) => setTimeout(res, 1500));
fetchArticle()
冒頭//* 重いローディング処理 */ 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>
はランダムなローディング時間を伴うため、読み込みが完了した記事から順に表示されるという、より自然でスムーズな体験が実現されます
差分(Step2 → Step3)
// 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" />;
+ }
💡ローディング表示の内訳
page.tsx冒頭の以下の処理を待つ間、loading.tsx
が表示さています//* 重いローディング処理 */ await new Promise((res) => setTimeout(res, 1500));
そして、
fetchArticle
冒頭の以下の処理を待つ間に、Skeltonが表示されています//* 重いローディング処理 */ await new Promise((res) => setTimeout(res, 1000 + Math.random() * 2000));
💡 レイアウトシフトを防ぐための工夫
段階的に描画されることでUXは向上しますが、ローディング中のスケルトンと表示後のレイアウトが大きく異なると、「ガタつき(レイアウトシフト)」が起きてしまいUXを損なう可能性があります。
そのため、Skeleton
コンポーネントは最終的な ArticleCard
に近いサイズ・構成にするように工夫しています。
function Skeleton() {
return (
<div className="animate-pulse bg-gray-200 rounded h-20 w-full" />
);
}
🛠 開発中にローディング表示をしっかり確認したいときは?
Chrome の DevTools を使えば、ネットワーク速度を意図的に遅くしてローディングUIの挙動を確認できます。
手順:
- Chromeで対象ページ(例:
/articles
)を開く - DevTools を開き、
Network
タブへ - 上部の
No throttling
をSlow 4G
や3G
に変更 - ページをリロードして、ローディング表示のタイミングやアニメーションの流れを確認
💡 実際のユーザーは通信状況がさまざまなので、早い環境だけで開発・確認しないのはとても大事です!
まとめ
この記事では、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を意識したフロントエンド開発では、「表示が遅い=悪い」ではなく、「ユーザーにとって自然で安心できる表示」にすることが重要だと改めて感じました。
今回作成したコードはこちら