Next.js の App Router でデータ取得するとき、props のバケツリレー(prop drilling)に悩んでいませんか?Pages Router のgetServerSideProps時代から引き続き、親から子へデータを渡し続ける設計に疲れている方も多いと思います。
特にモバイル開発(React Native や SwiftUI)から来た方は、「Server Component って結局どう使えばいいの?」と戸惑うことが多いですよね。この記事では、Next.js 公式ドキュメントや GitHub Discussion で推奨されているベストプラクティスを整理して、バケツリレーを解消するパターンを紹介します。
バケツリレー問題とは何か
バケツリレー(英語では prop drilling)とは、親コンポーネントで取得したデータを、中間のコンポーネントを経由して深くネストした子コンポーネントまで渡し続けることです。
// ❌ バケツリレーの例
function Page({ data }) {
return <Layout data={data} />
}
function Layout({ data }) {
return <Sidebar data={data} /> // Layout自体はdataを使わない
}
function Sidebar({ data }) {
return <UserInfo data={data} /> // Sidebarも使わない
}
function UserInfo({ data }) {
return <p>{data.name}</p> // やっとここで使う
}
この設計の問題点は 3 つあります。
| 問題 | 影響 |
|---|---|
| 変更の影響範囲が大きい | propsの型を変えると、中間コンポーネントすべてを修正する必要がある |
| コンポーネントの移動が困難 | 別の場所に移動するとpropsの経路が変わり、大規模な修正が発生 |
| 可読性の低下 | どのコンポーネントが実際にデータを使うのか分かりにくい |
モバイル開発では状態管理ライブラリ(Redux、Zustand、Provider)でこの問題を回避することが多いですが、Next.js の Server Component では別のアプローチがあります。
なぜバケツリレーのコードが多いのか
実際の Next.js プロジェクトを見ると、page.tsxで fetch してバケツリレーしているパターンがかなり多いです。なぜでしょうか?
| 理由 | 説明 |
|---|---|
| Pages Routerの名残 | getServerSidePropsはページ単位でしかデータ取得できなかった。App Routerでも同じ書き方をしてしまう |
| コロケーションの認知度が低い | 「どこでfetchしても重複排除される」という仕組みを知らない |
| 初心者はバケツリレーの辛さを知らない | 小規模なうちは問題にならない。プロジェクトが大きくなってから苦しみ始める |
特に「自動重複排除」の仕組みは、Next.js 公式ドキュメントでも目立つ場所に書いてあるわけではありません。この記事を読んでいる方は、ぜひコロケーションを試してみてください。
Container/Presentationalパターンは今でも有効?
かつての React 開発では、Container Component(データ取得担当)と Presentational Component(表示担当)を分離するパターンが主流でした。HOC(Higher-Order Component)が流行していた時代から、このアプローチは広く使われてきました。
// 従来のContainer/Presentationalパターン
function UserContainer() {
const [user, setUser] = useState(null)
useEffect(() => {
fetchUser().then(setUser)
}, [])
return <UserPresentation user={user} />
}
function UserPresentation({ user }) {
return <p>{user?.name}</p>
}
このパターン自体は今でも有効です。アーキテクチャの考え方については フロントエンドにクリーンアーキテクチャは必要か? で詳しく解説しています。ただ、Next.jsのServer Componentではもっとシンプルな方法があります。結論から言うと、「データを使う場所で直接 fetch する」のが App Router 時代のベストプラクティスです。
解決策1:データを使う場所で直接fetchする
Next.js 公式が推奨するのは、コロケーション(colocation)と呼ばれるアプローチです。データを必要とするコンポーネント内で直接データを取得します。
ここで重要なのは、Server Component でのfetchはサーバー側で実行されるという点です。
[サーバー] [クライアント]
fetch() → データ取得
↓
JSX → HTML変換
↓
────────────────────────→ HTMLを受け取って表示
クライアント側ではfetchは実行されません。サーバーでデータ取得とレンダリングが完了してから、結果の HTML がブラウザに送られます。だから API キーをコード内で使っても安全ですし、データベースに直接アクセスできます。
// ✅ コロケーション:データを使う場所で取得
async function UserInfo() {
const user = await fetchUser() // 直接fetchする
return <p>{user.name}</p>
}
async function Page() {
return (
<Layout>
<Sidebar>
<UserInfo /> {/* propsでデータを渡さない */}
</Sidebar>
</Layout>
)
}
「でも、同じデータを複数のコンポーネントで使いたい場合は?毎回 fetch するの?」と思いますよね。その心配は不要です。
fetchの自動重複排除
Next.js は同一リクエスト内で同じ URL・オプションのfetch呼び出しを自動的に 1 回にまとめます。これをRequest Memoizationと呼びます。
// 同じfetchが複数箇所で呼ばれても、実際のリクエストは1回
async function Header() {
const user = await fetch('/api/user') // 1回目
return <p>Welcome, {user.name}</p>
}
async function Sidebar() {
const user = await fetch('/api/user') // 2回目(でも実際にはキャッシュから取得)
return <p>{user.email}</p>
}
海外の開発者コミュニティでは「fetch anywhere, dedupe automatically」(どこでも fetch して自動で重複排除)と表現されています。
解決策2:React cache()でDB/ORMアクセスを最適化
fetch以外のデータソース(Prisma、Drizzle など)を使う場合は、React のcache()関数を使います。
// lib/data.ts
import { cache } from 'react'
import 'server-only' // クライアントでの誤使用を防止
export const getUser = cache(async (id: string) => {
const user = await db.user.findUnique({ where: { id } })
return user
})
// 複数のコンポーネントから呼び出しても1回のクエリで済む
async function UserProfile({ userId }) {
const user = await getUser(userId)
return <h1>{user.name}</h1>
}
async function UserStats({ userId }) {
const user = await getUser(userId) // キャッシュから取得
return <p>Posts: {user.postsCount}</p>
}
Next.js の GitHub Discussion では、このパターンがnext-intlなどの主要ライブラリでも採用されていると報告されています。
server-onlyでセキュリティを担保
server-onlyパッケージをインポートすると、そのモジュールがクライアントコンポーネントで import された場合にビルドエラーになります。API キーやデータベース接続情報の漏洩を防げます。
解決策3:Suspenseで段階的に表示する
データ取得に時間がかかる場合、Suspenseで囲んでローディング状態を表示できます。
import { Suspense } from 'react'
export default function Page() {
return (
<div>
<h1>ダッシュボード</h1>
<Suspense fallback={<p>ユーザー情報を読み込み中...</p>}>
<UserInfo />
</Suspense>
<Suspense fallback={<p>統計を読み込み中...</p>}>
<Stats />
</Suspense>
</div>
)
}
developerway.com のベンチマークによると、Suspense を正しく設定した場合の LCP(Largest Contentful Paint)は約1.28秒で、従来の SSR より高速です。ただし、Suspense境界を忘れるとストリーミング効果がゼロになるという注意点があります。
Client ComponentからServer Componentを使いたい場合
「Client Component で Server Component をネストしたいんだけど、どうすればいい?」という疑問をよく見かけます。モバイル開発ではこの概念がないので、最初は混乱しますよね。
答えはchildrenパターンです。
// Modal.tsx(Client Component)
'use client'
import { useState } from 'react'
export function Modal({ children }) {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button onClick={() => setIsOpen(true)}>開く</button>
{isOpen && (
<div className="modal">
{children} {/* ここにServer Componentが入る */}
</div>
)}
</>
)
}
// Page.tsx(Server Component)
import { Modal } from './Modal'
import { UserDetail } from './UserDetail' // Server Component
export default function Page() {
return (
<Modal>
<UserDetail /> {/* Server Componentをchildrenとして渡す */}
</Modal>
)
}
このパターンのポイントは、Server Componentは親のServer Componentでレンダリングされてから、Client Componentに渡されるという点です。Client Component が Server Component を直接 import できませんが、children として受け取ることは可能です。
Server ComponentとClient Componentの使い分け
Next.js 公式の推奨をまとめると以下のようになります。
| 用途 | Server Component | Client Component |
|---|---|---|
| データ取得 | ✅ 推奨 | ❌ |
| シークレット管理 | ✅ 安全 | ❌ 漏洩リスク |
| ユーザー操作 | ❌ | ✅ 必須 |
| 状態管理(useState等) | ❌ | ✅ 必須 |
| ブラウザAPI | ❌ | ✅ 必須 |
基本は「Server Component をデフォルトにして、必要な場所だけ Client Component にする」です。
SPAやモバイル開発者へのヒント:状態管理ライブラリの出番が減る
SPA やモバイル開発では、Redux などの状態管理ライブラリに「API から取得したデータ」を入れるのが普通でした。でも Server Component では、サーバーで取得したデータはそのまま props や JSX で使えます。
// ❌ SPAの癖:取得したデータをグローバルstoreに入れる
// → Server Componentでは不要
// ✅ Server Component:取得したデータをそのまま使う
async function Dashboard() {
const user = await getUser()
const stats = await getStats()
return (
<div>
<h1>{user.name}さんのダッシュボード</h1>
<StatsCard stats={stats} />
</div>
)
}
状態管理ライブラリが必要なのは、クライアント側でのみ必要な状態だけです。
| 状態管理が必要 | 状態管理が不要 |
|---|---|
| モーダルの開閉 | APIから取得したデータ |
| フォームの入力値 | ユーザー情報 |
| UIのトグル状態 | 記事一覧 |
| 楽観的更新の一時状態 | 設定値 |
モバイル開発の感覚だと「取得したデータはどこかに保存しないと」と思いがちですが、Server Component では毎回サーバーで fetch してレンダリングするので、その必要がありません。
並列データ取得で高速化
複数のデータを取得する場合、Promise.allで並列化すると大幅に高速化できます。
// ❌ 順次実行(遅い)
async function Page({ params }) {
const user = await getUser(params.id)
const posts = await getPosts(params.id) // userの完了を待ってから実行
// ...
}
// ✅ 並列実行(高速)
async function Page({ params }) {
const [user, posts] = await Promise.all([
getUser(params.id),
getPosts(params.id)
])
// ...
}
パフォーマンスの現実:数字で見る効果
developerway.com の検証によると、各パターンの LCP(初回読み込み時間)は以下の通りです。
| パターン | LCP |
|---|---|
| CSR(クライアントのみ) | 4.1秒 |
| SSR + クライアントデータ取得 | 1.61秒 |
| Next.js App Router + Suspense | 1.28秒 |
ただし、ページがインタラクティブになるまでは約 3.8 秒かかるという報告もあります。Server Components は銀の弾丸ではなく、Suspense境界の適切な設計が重要です。
まとめ
Next.js の Server Component でバケツリレーを解消する方法を整理しました。
- データを使う場所で直接fetchする(コロケーション)
- React cache()でDB/ORMアクセスを最適化
- Suspenseで段階的に表示
- childrenパターンでClient Component内にServer Componentをネスト
従来の Container/Presentational パターンや状態管理ライブラリに頼らなくても、Next.js の仕組みを活用すればシンプルにデータを共有できます。モバイル開発から来た方は、「サーバーでのデータ取得 + 自動重複排除」という考え方に慣れると、App Router の良さが分かってくると思います。