Next.js App Routerでpropsバケツリレーに悩んだ末にたどり着いたデータ設計

開発

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がインターフェースだと気づいたとき、なんか腑に落ちた。
筆者

でもここで問題が出てくる。ユーザー情報やテーマみたいな「アプリ全体で使うデータ」を毎回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} />
}
‘use server’がエンドポイント宣言だと知ったとき、色々つながった。
筆者

じゃあ「Server Actionsで読み取りもやればいいのでは」と思うかもしれない。Client Componentから'use server'の関数でデータ取得すれば、APIルートを書かなくて済む。

これがアンチパターン。Richard Kovacsの記事で詳しく解説されているんだけど、Server Actionsは内部的にPOSTリクエストを使っている。Client Componentから呼ぶたびにサーバーへのネットワーク往復が発生する。しかもCache-Controlno-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はサーバーでレンダリングされる。

これ最初に知ったとき「え、Client Componentで囲んでも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間のデータ共有を詳しく書いているので、合わせてどうぞ。

Thanks for reading!
Next.jsReactApp RouterServer ComponentsContext Provider