Next.js App Routerに入門して最初に感じたのは「Container Componentが使えない」ということだった。Client Only時代はContainer Componentでデータを丸ごと取得して、子コンポーネントに流すだけでよかった。でもApp RouterにはServer/Client境界がある。この境界のせいで、データの届け方が2系統に分かれた。propsで渡すか、Providerで共有するか。その判断基準が最初は全然わからなかった。
Container Componentが通用しない世界
ReduxやReact Query全盛期、データ取得の定番パターンはこうだった。
// Client Only時代: Container Componentパターン
function UserPageContainer() {
const { data: user } = useQuery(['user'], fetchUser)
const { data: posts } = useQuery(['posts'], fetchPosts)
return <UserPage user={user} posts={posts} />
}
Container Component(Smart Component)がデータを取得して、Presentational Component(Dumb Component)に流す。取得と表示がきれいに分かれていて、シンプルだった。
App Routerではこの構造が崩れる。Server Componentはサーバーでしか動かない。Client Componentはインタラクションが必要なときだけ使う。この2つの間に境界線がある。
Client Only時代:
Container (データ取得) → Presentational (表示)
↑ 全部クライアントで完結
App Router時代:
Server Component (データ取得)
│
│ ← ここに境界がある
▼
Client Component (インタラクション)
境界を跨ぐには、propsでシリアライズ可能なデータを渡す。関数やクラスインスタンスは渡せない。propsはServer/Client間のインターフェース(契約)になった。
Next.js App RouterでDate型をpropsに渡せない問題と現実的な解決策開発でもここで問題が出てくる。ユーザー情報やテーマみたいな「アプリ全体で使うデータ」を毎回propsでバケツリレーするのか? それは違う。
‘use server’の本当の意味
App Routerを触り始めたとき、データ取得に'use server'を付けていた。Server Componentで呼ぶんだからServer Actionsでしょ、と。
でもこれは誤解だった。
'use server'の意味は「この関数をClient Componentから呼べるようにする」ということ。サーバーで実行するためのマーカーじゃない。Client Componentからサーバーのコードを呼ぶためのエンドポイントを作る宣言。
// 'use server' = Client Componentから呼べるエンドポイント
'use server'
export async function deletePost(id: string) {
await db.post.delete({ where: { id } })
revalidatePath('/posts')
}
// Client Componentからボタンクリックで呼ぶ。これが本来の使い方
'use client'
import { deletePost } from './actions'
export function DeleteButton({ id }: { id: string }) {
return <button onClick={() => deletePost(id)}>削除</button>
}
Server Componentでデータを取得するだけなら、'use server'は要らない。サーバー上で動いてるんだから、普通のasync関数を直接呼べばいい。
// Server Componentからの読み取り。'use server'は不要
import { getUser } from '@/lib/dao/users' // ただのasync関数
export default async function Page() {
const user = await getUser() // サーバーで直接実行。ネットワーク通信なし
return <Profile user={user} />
}
じゃあ「Server Actionsで読み取りもやればいいのでは」と思うかもしれない。Client Componentから'use server'の関数でデータ取得すれば、APIルートを書かなくて済む。
これがアンチパターン。Richard Kovacsの記事で詳しく解説されているんだけど、Server Actionsは内部的にPOSTリクエストを使っている。Client Componentから呼ぶたびにサーバーへのネットワーク往復が発生する。しかもCache-Controlがno-store, must-revalidateに設定されるから、ブラウザキャッシュも効かない。
Server Componentから直接呼べば、ネットワーク通信ゼロでデータが取れる。わざわざPOSTリクエストを挟む理由がない。
| やりたいこと | 使うもの |
|---|---|
| Server Componentでデータ取得 | 普通のasync関数 + server-only |
| Client Componentからデータ変更 | Server Actions ('use server') |
| Client Componentにデータを渡す | propsまたはProvider |
読み取りと書き込みを分離する
この理解があると、Robin WieruchやNext.js公式が推奨しているパターンがすんなり入ってくる。海外ではData Access Layer(DAL)パターンと呼ばれてる。
/lib/dao/ ← 読み取り専用(Server Componentから直接呼ぶ)
users.ts
posts.ts
/lib/actions/ ← 書き込み専用('use server' + mutation)
users.ts
posts.ts
読み取りはServer Componentで直接awaitする。書き込みだけServer Actions。
// lib/dao/users.ts -- 読み取り専用
import 'server-only'
import { cache } from 'react'
export const getUser = cache(async (id: string) => {
return await db.user.findUnique({ where: { id } })
})
// lib/actions/users.ts -- 書き込み専用
'use server'
import { revalidatePath } from 'next/cache'
export async function updateUser(formData: FormData) {
const name = formData.get('name') as string
await db.user.update({ where: { id: userId }, data: { name } })
revalidatePath('/profile')
}
server-onlyパッケージをimportしておくと、Client Componentから誤ってimportしたときにビルドエラーになる。境界の安全弁。
ここまではServer Component同士の話。でも「アプリ全体でユーザー情報を使いたい」「テーマをどこからでも切り替えたい」みたいなケースはどうする?
propsとProviderの判断基準
全部Providerに入れればいいわけじゃない。ここが今回の本題。
判断はシンプルで、2つの質問で決まる。
1つ目: そのデータはClient Componentで使うか?
Server Componentでしか使わないなら、React.cache()で各コンポーネントが自分で取得すればいい。Providerは不要。
2つ目: そのデータは複数の離れたClient Componentで共有するか? 1つのClient Componentでしか使わないなら、propsで渡せば十分。親子が2-3階層以内ならバケツリレーにもならない。
複数の離れたClient Componentで同じデータを参照する場合だけ、Providerの出番。
データの種類は?
│
├─ Server Componentでしか使わない
│ → React.cache()で各自取得。Provider不要
│
├─ 特定のClient Component 1つで使う
│ → Server Componentからpropsで渡す
│
├─ 近い親子間(2-3階層)で共有
│ → propsで渡す。Provider不要
│
└─ 離れた複数のClient Componentで共有
→ Provider
実際のコードで見たほうが早い。
propsで渡すもの
ページ固有のデータ。投稿の中身、商品の詳細、一覧のアイテム。
// page.tsx (Server Component)
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await getPost(params.id)
return (
<article>
<h1>{post.title}</h1>
<PostContent content={post.content} />
<LikeButton postId={post.id} initialLikes={post.likes} />
</article>
)
}
LikeButtonはClient Component(onClickが必要)だけど、初期値はServer Componentからpropsで渡せる。
Providerで共有するもの
テーマ、認証セッション、ロケール。アプリ全体で参照されて、複数のClient ComponentからuseContextで取り出すデータ。
// providers/auth-provider.tsx
'use client'
import { createContext, useContext, useState } from 'react'
type Session = { id: string; name: string; role: string }
const AuthContext = createContext<Session | null>(null)
export function AuthProvider({
initialSession,
children
}: {
initialSession: Session | null
children: React.ReactNode
}) {
const [session] = useState(initialSession)
return <AuthContext.Provider value={session}>{children}</AuthContext.Provider>
}
export const useAuth = () => useContext(AuthContext)
// app/layout.tsx (Server Component)
import { AuthProvider } from '@/providers/auth-provider'
import { getSession } from '@/lib/dao/auth'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const session = await getSession() // サーバーで取得
return (
<html>
<body>
<AuthProvider initialSession={session}>
{children}
</AuthProvider>
</body>
</html>
)
}
ここで大事なのは、Server Component(layout.tsx)でデータを取得して、Provider(Client Component)にpropsで渡してるところ。Providerが'use client'でも、children経由で渡されたServer Componentはサーバーでレンダリングされる。
Next.js公式もこう書いている:
Providerはツリーのできるだけ深い位置にレンダリングすべき。
{children}のみをラップし、<html>全体をラップしないこと。
Providerを使うときの注意点
Providerに何でも突っ込むと、パフォーマンスが悪化する。DeveloperWayの検証結果が分かりやすい。
Contextの値が変わると、その値を実際に使っていないConsumerまで全部再レンダリングされる。テーマ・認証・サイドバーの状態を1つのContextにまとめると、テーマを切り替えただけでアプリ全体が再レンダリング。
DeveloperWayの実測では、1つのContextに全部入れた状態で名前入力すると5コンポーネントが再レンダリングされた。StateとAPIを分離したら2コンポーネントだけ。再レンダリング数が60%減った。
対策はStateとAPIの分離。
// State: データの値(変わる)
const FormDataContext = createContext<State>({} as State)
// API: 操作関数(変わらない)
const FormAPIContext = createContext<API>({} as API)
export function FormProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState)
// dispatchは安定参照なので依存配列を空にできる
const api = useMemo(() => ({
updateName: (name: string) => dispatch({ type: 'updateName', name }),
updateTheme: (theme: string) => dispatch({ type: 'updateTheme', theme }),
}), [])
return (
<FormAPIContext.Provider value={api}>
<FormDataContext.Provider value={state}>
{children}
</FormDataContext.Provider>
</FormAPIContext.Provider>
)
}
ボタンのクリックハンドラだけ使うコンポーネントはAPI Contextだけを購読する。State Contextの変更には反応しない。
get系とuse系で分ける命名規則
ここまでのパターンを整理すると、データ取得関数は自然と2種類に分かれる。
getUser, getPost — サーバー側のデータ取得。DAL関数。Server Componentから直接呼ぶ。
// lib/dao/users.ts
export const getUser = cache(async () => { ... })
// page.tsx
const user = await getUser()
useAuth, useTheme — クライアント側のデータ取得。Context経由のhook。Client Componentから呼ぶ。
// providers/auth-provider.tsx
export const useAuth = () => useContext(AuthContext)
// components/user-menu.tsx
'use client'
const session = useAuth()
この命名だけで「これはサーバーで呼ぶもの」「これはクライアントで呼ぶもの」が一目で分かる。
Reactでhooksをどこで注入すべきか?コロケーション vs リフトアップの判断基準開発
も合わせて読むと、この設計の全体像が見えてくる。
まとめ
Client Only時代はContainer Componentで全部取得して流すだけだった。App RouterではServer/Client境界ができて、データの届け方が2系統に分かれた。
'use server'は「Client Componentから呼べるようにする」宣言。Server Componentでの読み取りには不要- サーバーで取得してpropsで渡す(ページ固有のデータ)
- Providerで共有する(テーマ、認証など離れたClient Component間で使うデータ)
- Server Actionsは書き込み専用
全部Providerに入れるのは間違い。全部propsで渡すのもつらい。境界があるからこそ、どっちで届けるか判断する力が求められるようになった。
Next.js Server Componentでバケツリレーを解消する方法:データ取得パターン完全ガイド開発
ではReact.cache()を使ったServer Component間のデータ共有を詳しく書いているので、合わせてどうぞ。