こんにちは、かつコーチです。
前回は、Client ComponentがServer Componentを誤ってimportし、バンドルサイズが膨らんでしまった失敗談を紹介しました。
正しくComposition Patternで書き分けるには、Server Component側でどうデータを取得しているかを理解しておく必要があります。
今回は、Server Componentでのデータフェッチの基本をあらためて整理していきます。
Server Componentでデータフェッチとは?
そもそも何が便利なのか
Server Componentは、サーバー上で実行されるコンポーネントです。
そのため、コンポーネントの中で直接async/awaitを使ってデータを取得できます。
以前のReactやPages Routerの時代は、データ取得用のライフサイクル(useEffectやgetServerSideProps)を別途用意する必要がありましたが、App RouterのServer Componentではその手間が不要になりました。
useEffectでのフェッチとの違い
Client ComponentでuseEffectを使ってデータを取得する場合、まず空の画面が表示され、その後クライアント側でAPIを叩いて表示が差し替わる、という2段階の流れになります。
Server Componentのデータフェッチは、サーバー側でデータ取得が完了してからHTMLを生成するため、ユーザーには最初から完成した状態の画面が届きます。
これによりローディングのちらつきが減り、SEO的にも有利になるというメリットがあります。
基本の書き方
手順1:コンポーネントをasync関数にする
Server Componentでデータを取得する基本形は、コンポーネント自体をasync functionにすることです。
// app/posts/page.tsx
interface Post {
id: number;
title: string;
}
async function getPosts(): Promise<Post[]> {
const res = await fetch("https://api.example.com/posts", {
next: { revalidate: 60 }, // 60秒キャッシュを再検証する
});
if (!res.ok) {
throw new Error("投稿の取得に失敗しました");
}
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
export defaultのコンポーネント関数にasyncを付けているだけで、特別なHooksは使っていません。
fetchの第2引数にあるnext: { revalidate: 60 }は、Next.js独自の拡張オプションで、キャッシュを何秒ごとに再検証するかを指定するものです。
手順2:データベースへ直接アクセスする
Server Componentはサーバーでしか実行されないため、DBクライアントやORMを直接呼び出すことも可能です。
// app/posts/[id]/page.tsx
import { db } from "@/lib/db";
interface Props {
params: Promise<{ id: string }>;
}
export default async function PostDetailPage({ params }: Props) {
const { id } = await params;
const post = await db.post.findUnique({ where: { id: Number(id) } });
if (!post) {
return <p>投稿が見つかりませんでした</p>;
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}
APIルートを経由せず、DBの結果を直接JSXに埋め込めるのがServer Componentの大きな利点です。
クライアントにDB接続情報が漏れる心配もありません。
手順3:複数のデータを並列で取得する
複数のデータ取得をawaitで直列に書いてしまうと、片方が終わるまでもう片方が始まらず無駄に時間がかかります。
// app/dashboard/page.tsx
async function getUser() {
const res = await fetch("https://api.example.com/user");
return res.json();
}
async function getNotifications() {
const res = await fetch("https://api.example.com/notifications");
return res.json();
}
export default async function DashboardPage() {
const [user, notifications] = await Promise.all([
getUser(),
getNotifications(),
]);
return (
<div>
<h1>{user.name}さん、こんにちは</h1>
<p>通知:{notifications.length}件</p>
</div>
);
}
Promise.allでまとめることで、2つのリクエストが並列に走り、全体の待ち時間を短縮できます。
つまずきポイント:直列fetchでウォーターフォールが起きた
実際に起きたこと
私が最初にServer Componentでダッシュボード画面を作ったとき、素直にawaitを上から並べて書いていました。
❌ Before:awaitを直列に並べてしまう
export default async function DashboardPage() {
const user = await getUser(); // 300ms
const notifications = await getNotifications(); // 300ms
return (
<div>
<h1>{user.name}さん、こんにちは</h1>
<p>通知:{notifications.length}件</p>
</div>
);
}
getUserが終わってからgetNotificationsが始まるため、合計で600ms待つことになります。
原因と解決策
これは「ウォーターフォール(滝が連続して流れ落ちるように処理が直列に積み重なる状態)」と呼ばれる、よくあるパフォーマンス問題です。
Chrome DevToolsのNetworkタブでリクエストのタイミングを見て初めて、2つのリクエストが同時ではなく順番に発生していることに気づきました。
✅ After:Promise.allで並列化する
先ほどの手順3のようにPromise.allでまとめたところ、合計の待ち時間が300ms前後に短縮されました。
「データ同士に依存関係がないか」を意識し、依存がなければPromise.allでまとめて並列化する、というのを基本の型にするようにしています。
まとめ
この記事のポイント
- Server Componentはコンポーネント自体を
async functionにすることで、直接データフェッチができる fetchのnext: { revalidate }オプションでキャッシュの再検証間隔を制御できる- Server ComponentからはDBクライアントも直接呼び出せる
- 依存関係のない複数のデータ取得は、直列の
awaitではなくPromise.allで並列化してウォーターフォールを避ける
次に読むべき記事
データの取得ができたら、次はデータを送信・更新する側の仕組みも押さえておきたいところです。
次回は、Route Handlers(API Routes)でAPIエンドポイントを作る方法を解説します。
→ 次の記事:Route Handlers(API Routes)でAPIエンドポイントを作る