🚀

[先取り] Tanstack Start によるクライアントファーストな RSC のアプローチ

に公開

はじめに 🚀

React Server Components(RSC)は、Next.js App Router での採用を機に広く普及しました。一方で、RSC の採用方法については、フレームワークごとに異なる考え方が存在します。

TanStack の作者である Tanner Linsley 氏は、インタビュー[1]にて「RSC をバンドルサイズの削減や静的コンテンツの最適化に役立つツールと見ているが、万能な解決策とは考えていない」と述べています。TanStack Start は、Next.js とは異なるアプローチで RSC をサポートする予定であり、その背景にはクライアントファーストの哲学があります。

本記事では、GitHub の Discussion や Issue、公式ブログなどの一次情報をもとに、以下のテーマを深掘りします。

第1部:RSC の特性と課題 🔍

RSC とは何か - 従来の SSR との違い

React Server Components(RSC)は、React 18 以降で実験的に進められ、React 19 で安定化された新しいパラダイムです。従来の Server-Side Rendering(SSR)と混同されがちですが、根本的に異なる概念です。

従来の SSR の仕組み

従来の SSR では、サーバー上で React コンポーネントを HTML にレンダリングし、クライアントに送信します。その後、クライアント側でハイドレーション(Hydration)を実行します。ハイドレーションとは、サーバーで生成された静的な HTML に対して、イベントハンドラなどをアタッチし、インタラクティブな UI を完成させるプロセスです。

この方式の問題点は、クライアント側で全ての JavaScript を再度実行する必要があることです。大きなライブラリ(Markdown パーサー、シンタックスハイライターなど)を使用している場合、それらすべてがクライアントのバンドルに含まれます。

RSC の仕組み

RSC では、サーバー上で実行されるコンポーネント(Server Component)はクライアントに JavaScript を送信しません。代わりに、RSC Payload(シリアライズされた JSX)という特別なデータ形式で結果だけを送信します。

重要なのは、Server Component で使用したライブラリのコードはクライアントのバンドルに含まれないことです。これにより、バンドルサイズを削減できます。

RSC と SSR の関係

RSC は"静的プリレンダー(ビルド時)" と "リクエスト時のサーバー実行" のどちらでも動作可能です。ビルド時に実行すれば、実行環境にサーバーは不要で静的 HTML として配信できます。一方、SSR と組み合わせてリクエスト時に実行することも可能で、その場合は動的コンテンツの配信や SEO 最適化に活用できます。

RSCは静的配信も可能

元 React 開発者の Dan Abramov 氏のブログ overreacted.io は RSC で構築され、Cloudflare CDN から静的に配信されています。ビルド時に RSC を実行し、静的な HTML を生成することで、サーバーを必要とせず、ホスティングコストはゼロです。

これは「RSC = 常にサーバーが必要」という誤解を覆す好例です。RSC はビルド時実行、静的 HTML 出力、CDN 配信が可能であり、サーバー専用ライブラリ(Markdown パーサーなど)をビルド時に使用しつつ、クライアントバンドルから除外できます。

データコロケーションの課題

Tanner Linsley 氏は、「RSC をバンドルサイズの削減や静的コンテンツの最適化に役立つツールと見ているが、万能な解決策とは考えていない」と述べています[1:1]

Next.js App Router のような Server Component ファーストのアプローチでは、ブログやマーケティングページなどのコンテンツサイトには最適ですが、高度にインタラクティブなアプリケーションでは課題があります。特にデータコロケーションの面で問題が生じます。

データコロケーションとは、データ要求をそのデータを使う場所に配置するパターンです。コンポーネント内でデータフェッチのロジックを定義することで、コンポーネントが必要とするデータが明確になり、独立性と保守性が高まります。従来のクライアントサイドアプローチでは自然に書けましたが、Server Component ファーストのアプローチではインタラクティブな操作が必要な場合に課題が生じます。

インタラクティブな UI での課題

// ❌ Client Component を使う場合:データの巻き上げが必要
async function Dashboard() {
  // 親で全データを並列取得(Promise.all)
  const [salesData, tasksData] = await Promise.all([
    fetchSalesData(),
    fetchTasksData()
  ])

  // Client Component に props で渡す
  return (
    <>
      <SalesWidget data={salesData} /> {/* 'use client' */}
      <TaskWidget data={tasksData} /> {/* 'use client' */}
    </>
  )
}

この「巻き上げ」パターンでは、Promise.all で並列実行は可能ですが、各コンポーネントのデータフェッチロジックを親に移動させる必要があるため、データコロケーションが失われ、コンポーネントの独立性が損なわれます。

Tanner 氏は 2024年6月のツイート[2]で「ダッシュボードウィジェットや生産性アプリのような、コンポーネントレベルでデータの動的性が求められるプロジェクトでは問題になる」と指摘しています。複数の独立したウィジェットがある場合、親コンポーネントが肥大化し、コードが単調でエラーが発生しやすくなります。

Tanner 氏は同じツイートで、Reactが render-as-you-fetch(データの巻き上げ)を推奨することで「データコロケーションという優れた開発体験を犠牲にしている」とさらに踏み込んで批判しています。

SSR がこうした課題と深く関わっており、TanStack Start はデータコロケーションと render-as-you-fetch の両立を目指していることがわかります。

Server Component でのデータコロケーション

インタラクティブな操作が不要な静的コンテンツであれば、Server Component 同士でデータコロケーションを維持できます。

// ✅ 静的コンテンツの場合:データコロケーション維持
async function Dashboard() {
  return (
    <Suspense fallback={<Loading />}>
      {/* 各コンポーネントが独立してデータ管理 */}
      <SalesWidget />
      <TaskWidget />
    </Suspense>
  )
}

async function SalesWidget() {
  const data = await fetchSalesData()
  return <div>{/* 静的な表示のみ */}</div>
}

また、Server Actions を使用することで、Server Component 内でもフォーム送信などのインタラクティブな操作が可能です。

// ✅ Server Component + Server Actions
async function TaskWidget() {
  const tasks = await fetchTasksData()

  async function updateTask(formData: FormData) {
    'use server'
    // タスク更新処理
    revalidatePath('/dashboard')
  }

  return (
    <div>
      {tasks.map(task => (
        <form action={updateTask} key={task.id}>
          <input type="hidden" name="id" value={task.id} />
          <button type="submit">完了</button>
        </form>
      ))}
    </div>
  )
}

ただし、これが有効なのは以下のような場合に限られます。

  • フォーム送信、ボタンクリックによるデータ更新など、サーバーでの処理後にページを再レンダリングする操作
  • Progressive Enhancement(JavaScript 無効でも動作)が必要な場合

一方、以下のような高度にインタラクティブな操作が必要な場合、依然として課題があります。

  • リアルタイムなクライアント側ステート管理(入力値の即座のバリデーション表示、楽観的更新など)
  • ユーザー操作に応じた即座の UI 更新(ドラッグ&ドロップ、リアルタイムフィルタリングなど)
  • 複雑なクライアント側のロジック(アニメーション、きめ細かいキャッシュ管理など)

このような場合、コンポーネントを Client Component にする必要があり、結局データの巻き上げ問題に戻ります。Tanner 氏が指摘しているのは、まさにこのような高度にインタラクティブなアプリケーション(ダッシュボードウィジェットや生産性アプリなど)における課題です。

第2部:TanStack Start の RSC 実装 🎯

RSC の実装方法

TanStack Start では、createServerFn から JSX を返すことで RSC を実装します。

公式実装例

以下は Basic RSC Example からの実際のコードです:

https://github.com/TanStack/router/blob/c62883e5276f54899d35fd5ebbbcb9687eb27819/examples/react/start-basic-rsc/src/routes/posts.%24postId.tsx#L7-L45

公式実装での動作フローは以下の通りです。

  1. ルートアクセス: ユーザーがページにアクセス(例: /posts/1
  2. Loader 実行: サーバー側で loader が実行され、Server Function を呼び出し
  3. Server Function 実行: createServerFn で定義された handler がサーバー上で実行され、JSX を生成
  4. RSC ペイロード生成: JSX がシリアライズされた RSC ペイロード(ストリーム)に変換
  5. クライアント送信: RSC ペイロードがクライアントに送信
  6. コンポーネントレンダリング: useLoaderData() で JSX を取得し、React が通常のコンポーネントと同様に描画

この実装は、loader で Server Function を呼び出し、useLoaderData() で JSX を取得して表示するというシンプルなパターンです。既存の TanStack Router の知識がそのまま活用できます。

その他の呼び出しパターン(将来的な可能性)

Tanner Linsley 氏は Discord でのコメント[3]で「from a loader, or useQuery or whatever」「anywhere, not just loaders」と述べており、useQuery での呼び出しや、他の場所からの呼び出しもサポートする方針が示唆されています。

以下は、この発言に基づく将来的な可能性として予想したパターンです。

useQuery で直接呼び出し(将来の可能性)

⚠️ 注意: 重複しますが、このパターンは現時点では公式実装例に含まれていません。将来実装される可能性がある予想例です。

// Server Function の定義
const renderPost = createServerFn({ method: 'GET' })
  .inputValidator((postId: string) => postId)
  .handler(async ({ data }) => {
    const post = await fetchPost(data)

    return (
      <div className="space-y-2">
        <h4 className="text-xl font-bold underline">{post.title}</h4>
        <div className="text-sm">{post.body}</div>
      </div>
    )
  })

// コンポーネント内で useQuery を使用
function PostComponent() {
  const { postId } = Route.useParams()

  // useQuery で Server Function を直接呼び出し
  const { data: postJsx, isLoading, isError } = useQuery({
    queryKey: ['post', postId],
    queryFn: () => renderPost({ data: postId }),
  })

  if (isLoading) return <div>読み込み中...</div>
  if (isError) return <div>エラーが発生しました</div>

  return postJsx
}
TanStack Query による RSC 管理の利点

Tanner Linsley 氏は複数のインタビュー[4]で、RSC に対して独自の洞察を示しました。
「Server Components は streams of serialized JSX として扱うべきで、
他の非同期データソースと何ら変わらない」という考え方です。

RSC の本質は、サーバー上の一瞬を捉え、一部のコンポーネントに部分適用し、その JSX をクライアントに送信することです。これは他の非同期状態を管理するのと何ら変わりません。

Next.js では RSC が変更された際に revalidate を実行します。しかし、revalidation といえば Tanstack Query の得意分野です。RSC も結局は非同期データであり、非同期状態を意味します。Tanstack Query のフロントページにある項目—古いデータ、無効化のタイミング、キャッシュ方法、stale-while-revalidate—はすべて RSC にも当てはまります。

粒度の高い制御と無効化

Next.js 16 では、キャッシング API が改善され、revalidateTag()cacheLife プロファイルが追加され、updateTag() により即座の更新が可能になりました。しかし、基本的にはタグ単位での revalidation です。

// Next.js 16 の改善例
'use server'
import { updateTag } from 'next/cache'

export async function updateUserProfile(userId: string, profile: Profile) {
  await db.users.update(userId, profile)
  // 即座に更新(read-your-writes)
  updateTag(`user-${userId}`)
}

これは確かに改善ですが、GitHub Discussion #66228 では revalidateTag を使用しても「ページ全体が再レンダリングされる」という報告があり、コンポーネント単位での細かい制御は依然として困難に思います。

TanStack Start では、TanStack Query を活用することで、より細かい粒度での制御が可能になります(将来的に予定)。

// TanStack Start での将来的な実装イメージ
function Dashboard() {
  const queryClient = useQueryClient()

  // 各 RSC を独立して管理
  const { data: users } = useQuery({
    queryKey: ['users'],
    queryFn: () => getUsersRSC(),
    staleTime: 10 * 60 * 1000,
  })

  const { data: invoices } = useQuery({
    queryKey: ['invoices'],
    queryFn: () => getInvoicesRSC(),
    staleTime: 5 * 60 * 1000,
  })

  const handleUpdateInvoice = async () => {
    await updateInvoice()

    // invoices だけ無効化
    queryClient.invalidateQueries(['invoices'])
    // users は影響を受けず、ページ全体も再レンダリングされない
  }

  return (
    <div>
      {users}
      {invoices}
    </div>
  )
}

複数のネストされたルートがある場合、それぞれが同時に並列で RSC をトリガーできます。TanStack Query で管理すれば、「この RSC は常に最新でなければならない」「この RSC は 10 分ごとでよい」といった細かい制御が可能になります。

ミューテーションキーも RSC に適用できるようになれば、「この RSC は post ID と user ID を消費する」と指定すれば、それらの変数が変更されると、関連する RSC だけが再実行されます。アプリ全体の React ツリーを再構築する代わりに、invoices に関連する RSC だけが再検証されます。

この方式により、以下が実現されます(将来的に予定)。

  • より細かい粒度の制御:各 RSC に個別の staleTime や cacheTime を設定可能
  • 選択的な無効化:queryKey 単位での無効化により、他のコンポーネントに影響を与えない
  • 並列実行:複数の RSC を同時に取得
  • データコロケーション維持:各コンポーネントが独立してデータを管理

その他関連トピック

Isomorphic Loaders について

TanStack Start のIsomorphic Loadersは、サーバーとクライアント両方で動作するデータフェッチの仕組みです。初回 SSR 時はサーバーで実行され、クライアントナビゲーション時はクライアントで実行されます。開発者は環境を意識せず、1つのコードで最適なパフォーマンスが得られます。

Isomorphic Loaders はデータフェッチを担当し、RSCはUI レンダリングを担当する別の技術です。Isomorphic Loaders は RSC なしでも機能するため、TanStack のアプローチは「まず強力なクライアントアプリを構築してから、バンドルサイズ削減が重要な静的コンテンツに対して部分的に RSC を使う」という段階的な導入を想定しています。

将来的な使い分け

RSC サポート後は、アプリケーションの特性に応じて最適なツールを選択できるようになります。

Isomorphic Loaders RSC(将来)
返り値 データ(JSON) UI(JSX)
最適な用途 動的でインタラクティブなデータ
TanStack Query の高度なキャッシュ活用
静的コンテンツ
バンドルサイズの極限削減
// ✅ Isomorphic Loaders: データのみ(現在)
export const Route = createFileRoute('/dashboard')({
  loader: async () => await fetchDashboardData(),
})

// ✅ RSC: UI も含む(将来)
const DashboardUI = createServerFn()
  .handler(async () => {
    const data = await fetchDashboardData()
    return <div>{data.title}</div> // JSX を返す
  })

技術的な課題と進捗

RSC の完全な統合に向けて、いくつかの技術的課題が解決されつつあります。

Streaming SSR の安定化

Issue #3117で報告されているように、Streaming SSR と Suspense の組み合わせで複雑な問題が発生していますが、これらの安定化は将来の RSC 実装に向けた重要な前提条件です。

RSC はこれらの React 18 の機能を活用してサーバー側レンダリングとクライアント側ハイドレーションを実現するため、Streaming SSR と Suspense が正しく動作することが求められます。

// Streaming SSR の例
export const Route = createFileRoute('/stream')({
  loader: async () => {
    return {
      stream: new ReadableStream({
        // ストリーミングでデータを送信
      })
    }
  }
})
現在解決中の課題
  • Abort Signal とストリーミングの統合(Issue #4651
    • 2025年7月に報告
    • クライアントがキャンセルしてもストリーミング Server Functions が継続実行
    • レスポンス作成直後に abort listener が削除される
    • 影響:メモリリーク、不要な処理の継続
    • 状況:未解決
  • Middleware のバンドリング最適化(Issue #2783
    • サーバー専用コード(Supabase、ClickHouse クライアント等)がクライアントバンドルに含まれる
    • node:async_hooks 等の Node.js 専用モジュールがブラウザ用に externalize される問題
    • ツリーシェイキングとコード分離の改善が必要

第3部:設計哲学と Next.js との比較 🔄

TanStack Start の設計哲学

TanStack Start は、Next.js とは根本的に異なる哲学で RSC をサポートする予定です。

クライアントファースト

公式サイトには以下のように明記されています。

"While other frameworks continue to compromise on the client-side application experience we've cultivated as a front-end community over the years, TanStack Start stays true to the client-side first developer experience"

これは クライアントサイド・ファースト + フルスタック というアプローチです。

Tanner Linsley 氏は、複数のインタビュー[5]で TanStack Start について「アプリケーション全体をひっくり返すことなく使える方法で構築している」と述べています。具体的には、全てのコンポーネントを Server Component にする全面的な移行を強制せず、必要な部分だけ RSC を使える段階的な採用を可能にし、TanStack Query や Router の既存メカニズムと調和させるアプローチです。

RSC は「選択肢の一つ」

Tanner 氏は Strapi のインタビュー[5:1]で、RSC 全面採用の Next.js アプローチではなく、「RSC が正しい場合は使い、そうでない場合は使わない」という柔軟性を開発者に与えたいと考えていることを明らかにしています。

また Syntax Podcast[4:1]で、クライアント側のアプローチについて「何年もの間 React の中心であり、ほとんどの場合かなりうまく機能してきた。今でもかなりうまく機能している」と述べ、特定の問題を経験していない限り、このパターンから離れる必要はないと主張しています。

透明性:マジックなブラックボックスではない

TanStack Start は、RSC を「マジックなブラックボックス」として扱うのではなく、シリアライズされた JSX のストリームという本質を開発者に見せる設計を目指しています[5:2]

TanStack Start では、キャッシングやデータフェッチの動作を明示的に制御できるようにすることで、開発者が予測可能な動作を保証します。キャッシング、トランスポート、レンダリングを細かく調整でき、「なぜこの動作になるのか」が理解しやすい設計を目指しています。

Tanner 氏は Syntax Podcast[4:2]で、API 設計における「マジック」と「コントロール」のバランスについて語っています。TanStack Start は、開発者がキャッシュ、データ転送、レンダリングの各プロセスに対して明示的な制御とカスタマイズ性を持てるよう設計されており、これが「マジックなブラックボックス」を避けるという哲学の根幹にあります。

Next.js との比較

Next.js App Router は RSC を全面的に採用し、デフォルトでサーバーコンポーネントとして動作します。これに対し TanStack Start は、クライアントファーストを維持しながら RSC を段階的に導入できる設計を目指しています。

フレームワーク比較

観点 Next.js App Router TanStack Start(予定)
デフォルト Server Component('use client' でオプトアウト) クライアント実行可能なコンポーネント(createServerFn でサーバー機能を呼び出し)
RSC 採用 全面的(オールイン) 段階的・選択的
哲学 Server Component ファースト クライアント実行ファースト
データフェッチ コンポーネント内で直接 await 可能 createServerFn + loader または useQuery(予想)
データコロケーション インタラクティブな UI では「巻き上げ」が必要 TanStack Query により各コンポーネントで独立して管理可能

主な利点

TanStack Start のアプローチにより、以下が実現されます。

  • 段階的な RSC 導入 - アプリケーション全体を RSC に移行する必要がなく、必要な部分だけ選択的に使える
  • データコロケーションの維持 - TanStack Query との統合により、各コンポーネントが独立してデータを管理でき、親コンポーネントでデータを取得して子に props で渡す「巻き上げ」パターンが不要
  • エコシステムの分断を防ぐ - クライアントファーストの開発体験を維持し、既存のツール(TanStack Query、Router)との調和を保つ
  • 開発者の選択肢を増やす - Next.js が RSC 中心、React Router v7 が従来型 SSR を提供する中、TanStack Start はバランス型で柔軟性を重視した新しい選択肢を提供

まとめ 🖌️

React Server Components(RSC)は、バンドルサイズ削減や静的コンテンツ最適化に有効なツールですが、万能ではありません。高度にインタラクティブなアプリケーションでは、従来のクライアント中心のアプローチが適している場合も多くあります。

TanStack Start は、Next.js とは根本的に異なる「クライアントファースト」の哲学で RSC をサポートする予定です。RSC を全面採用するのではなく、開発者が必要な部分でのみ選択的に使えるよう設計されています。これにより、既存のアプリケーションを大きく変更することなく、段階的に RSC の恩恵を受けることが可能になります。

重要なのは、フレームワークが全てを決めるのではなく、開発者が自分のアプリケーションに最適な選択をできる柔軟性です。TanStack Start の RSC 対応は、透明性と制御を重視し、React コミュニティに新しい選択肢を提供するものとなるでしょう。

以上です!

脚注
  1. Exploring TanStack Ecosystem with Tanner Linsley - Callstack Podcast ↩︎ ↩︎

  2. Tanner Linsley on X - データコロケーションとSSRについて ↩︎

  3. TanStack Discord - RSC discussion ↩︎

  4. Next Gen Fullstack React with TanStack - Syntax Podcast #833 ↩︎ ↩︎ ↩︎

  5. Building Better Apps with TanStack Start and Tanner Linsley - Strapi Blog ↩︎ ↩︎ ↩︎

Discussion