React 20 Server Componentsのベストプラクティス: RSC導入で変わるデータフェッチの常識と、クライアントコンポーネントとの使い分け

はじめに

「Reactのデータフェッチといえば、useEffectuseStateを使って、クライアントサイドでAPIを叩くのが当たり前。」 もしあなたがまだそう考えているなら、この記事はあなたの常識を覆すことになるかもしれません。

Next.js 13のApp Routerと共に本格的に導入されたReact Server Components(RSC)は、Reactアプリケーションのアーキテクチャ、特にデータフェッチの方法を根本から変えようとしています。もはや、すべてのコンポーネントがブラウザ上で動くわけではありません。サーバーでレンダリングを完結させ、クライアントにはインタラクティブなUIの部品だけを送る、という新しい時代が到来したのです。

しかし、この大きなパラダイムシフトは、多くの開発者に新たな問いを突きつけています。

  • 「Server ComponentとClient Componentは、具体的にどう使い分ければいいの?」
  • "use client"はどこに書くのが正解?」
  • useEffectでのデータフェッチは、もう時代遅れなの?」
  • 「SWRやReact Query(TanStack Query)はもう不要になる?」

この記事では、そんな疑問を抱えるあなたのために、React Server Componentsの核心を解き明かし、次世代のReact開発におけるベストプラクティスを徹底的に解説します。この記事を読み終える頃には、あなたはRSCを自信を持って使いこなし、より高速で、より効率的なWebアプリケーションを構築するための確かな知識を手にしていることでしょう。

なぜRSCは登場したのか? 従来のReact開発が抱えていた課題

React Server Componentsがなぜこれほど注目されているのかを理解するためには、まず従来のクライアントサイドレンダリング(CSR)やサーバーサイドレンダリング(SSR)が抱えていた課題を振り返る必要があります。

課題1: クライアントサイドでのデータフェッチの限界

SPA(Single Page Application)の普及以降、React開発の主流はクライアントサイドでのデータフェッチでした。しかし、このアプローチにはいくつかの構造的な問題が存在します。

ネットワークウォーターフォール問題

コンポーネントのレンダリングが完了してから、useEffect内でデータフェッチを開始するため、親コンポーネントのデータ取得が終わらないと子コンポーネントのデータ取得が始まらない、といった連鎖的な遅延(ウォーターフォール)が発生しがちでした。これにより、ページの表示完了までに時間がかかっていました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 典型的なウォーターフォール
function ProfilePage() {
  const { user } = useUser(); // 1回目のAPIコール

  // userが取得できてから、次のコンポーネントがレンダリングされる
  return user ? <UserDetails userId={user.id} /> : <Spinner />;
}

function UserDetails({ userId }) {
  const { posts } = usePosts(userId); // 2回目のAPIコール
  // ...
}

バンドルサイズの肥大化

データフェッチのためのライブラリ(axios, SWRなど)、状態管理ロジック、データ整形ロジックなど、すべてがJavaScriptバンドルに含まれ、クライアントに送信されます。アプリケーションが複雑になるほどバンドルサイズは増大し、初期ロード時間(Initial Load Time)を悪化させる大きな要因となっていました。

セキュリティリスク

APIキーやデータベースへの接続情報など、本来サーバーサイドに留めておくべき機密情報を、クライアントサイドのコードから(直接的でなくとも)扱わざるを得ないケースがありました。これにより、情報漏洩のリスクが高まります。

課題2: SSR/SSGの限界

これらの課題を解決するためにSSR(Server-Side Rendering)やSSG(Static-Site Generation)が登場しましたが、これらにもまた別の限界がありました。

  • SSRの課題: サーバーでページ全体のHTMLを生成するため初期表示は高速ですが、JavaScriptがダウンロードされ、ハイドレーションが完了するまでページはインタラクティブになりません。また、データフェッチが完了するまでHTMLの生成がブロックされるため、一部のデータ取得が遅いとページ全体の表示が遅れてしまいます。
  • SSGの課題: ビルド時にすべてのページを生成するため非常に高速ですが、動的なデータやユーザー固有のコンテンツを表示するには不向きでした。

これらの課題を解決し、「サーバーのパワーを最大限に活用しつつ、クライアントの豊かなインタラクティビティも損なわない」という、両者の"いいとこ取り"を目指して生まれたのが、React Server Componentsなのです。

RSCの核心: Server ComponentとClient Componentの徹底解説

RSCの最も重要な概念は、コンポーネントを「サーバーで実行されるもの」と「クライアントで実行されるもの」に明確に分離したことです。Next.jsのApp Routerでは、すべてのコンポーネントはデフォルトでServer Componentとして扱われます。

Server Component (デフォルト)

Server Componentは、その名の通りサーバーサイドでのみレンダリングされるコンポーネントです。

特徴:

  • useState, useEffect, useContextなどのフックや、onClickなどのイベントハンドラは使用できません。
  • ブラウザAPI(window, localStorageなど)にアクセスできません。
  • async/awaitを直接使用して、データフェッチや非同期処理を行えます。
  • データベースやファイルシステムなど、サーバーサイドのリソースに直接アクセスできます。
  • レンダリングされても、そのJavaScriptコードはクライアントのバンドルに含まれません。

Server Componentの最大の利点は、データソースの近くでコンポーネントをレンダリングできることです。これにより、データフェッチのレイテンシーを最小限に抑え、必要なデータだけをコンポーネントに埋め込んでクライアントに送信できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// app/posts/page.tsx (Server Component)
import db from '@/lib/db'; // サーバーサイドのDBクライアント

async function getPosts() {
  // サーバー上で直接データベースにクエリを発行
  const posts = await db.post.findMany();
  return posts;
}

export default async function PostsPage() {
  // コンポーネント自体を非同期関数にできる
  const posts = await getPosts();

  return (
    <main>
      <h1>All Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </main>
  );
}

このコードでは、APIエンドポイントを介さずに直接データベースからデータを取得しています。また、このコンポーネントのロジックはクライアントに送られないため、バンドルサイズはゼロです。

Client Component

Client Componentは、従来のReactコンポーネントと同じように、クライアント(ブラウザ)でレンダリングされ、インタラクティブな動作を担当します。

特徴:

  • ファイルの先頭に"use client";というディレクティブを記述する必要があります。
  • useState, useEffectなどのフックを使用して、状態管理や副作用を扱えます。
  • onClick, onChangeなどのイベントハンドラを定義できます。
  • ブラウザAPIにアクセスできます。

"use client"は、単なるマーカーではありません。これは、Server ComponentとClient Componentの境界線を定義する重要な役割を果たします。"use client"が書かれたファイルからインポートされるすべてのコンポーネントも、自動的にClient Componentとして扱われます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// components/Counter.tsx (Client Component)
"use client";

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

使い分けの基本フロー

では、具体的にどう使い分ければ良いのでしょうか。以下のフローチャートを参考に判断するのがおすすめです。

graph TD
    A[コンポーネント開発開始] --> B{インタラクティブな要素は必要?<br>(onClick, onChangeなど)};
    B -- Yes --> C[Client Component<br>("use client" を使用)];
    B -- No --> D{状態管理やライフサイクルフックは必要?<br>(useState, useEffectなど)};
    D -- Yes --> C;
    D -- No --> E{ブラウザ専用APIは必要?<br>(window, localStorageなど)};
    E -- Yes --> C;
    E -- No --> F[Server Component<br>(デフォルトのまま)];

基本原則は、「可能な限りServer Componentを使い、インタラクティブ性が必要な部分だけをClient Componentとして切り出す」ことです。

RSC時代のデータフェッチ戦略

RSCの導入により、データフェッチの考え方は大きく変わりました。クライアントでのuseEffectに頼るのではなく、サーバーでデータを取得することが第一の選択肢となります。

基本戦略: データフェッチはServer Componentで行う

Server Component内では、async/awaitを使って、まるでNode.jsのスクリプトを書くかのようにシンプルにデータをフェッチできます。

Next.jsは、標準のfetch APIを拡張しており、きめ細やかなキャッシュ戦略を可能にしています。

  • 静的データフェッチ (SSG相当): デフォルトの動作。ビルド時にデータを取得し、キャッシュします。
    1
    2
    3
    4
    
    // キャッシュを強制(デフォルト)
    fetch('https://...');
    // または明示的に
    fetch('https://...', { cache: 'force-cache' });
    
  • 再検証 (ISR相当): 指定した時間(秒)が経過すると、次にリクエストがあった際にバックグラウンドでデータを再取得します。
    1
    2
    
    // 60秒ごとにデータを再検証
    fetch('https://...', { next: { revalidate: 60 } });
    
  • 動的データフェッチ (SSR相当): リクエストごとに常に最新のデータを取得します。
    1
    2
    
    // キャッシュを利用しない
    fetch('https://...', { cache: 'no-store' });
    

これにより、getStaticPropsgetServerSidePropsといったNext.jsの旧来のAPIは不要になり、コンポーネント内でデータ要件を完結させることができます。

パターン1: Server Componentでデータをフェッチし、Client ComponentにPropsで渡す

これは最も基本的で強力なパターンです。データの取得と表示ロジックはServer Componentに任せ、ユーザーインタラクションが必要な部分だけをClient Componentとして分離します。

例: 記事一覧と、各記事の「いいね」ボタン

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// app/blog/page.tsx (Server Component)
import LikeButton from '@/components/LikeButton';
import { getPosts } from '@/lib/posts';

export default async function BlogPage() {
  const posts = await getPosts(); // サーバーで全記事データを取得

  return (
    <div>
      <h1>Blog</h1>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content}</p>
          {/* インタラクティブな部分だけClient Componentに分離 */}
          {/* 初期データはPropsとして渡す */}
          <LikeButton postId={post.id} initialLikes={post.likes} />
        </article>
      ))}
    </div>
  );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// components/LikeButton.tsx (Client Component)
"use client";

import { useState } from 'react';
import { incrementLikes } from '@/app/actions'; // Server Actionを利用

export default function LikeButton({ postId, initialLikes }) {
  const [likes, setLikes] = useState(initialLikes);
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    startTransition(async () => {
      // サーバー側の関数を直接呼び出してDBを更新
      const newLikes = await incrementLikes(postId);
      setLikes(newLikes);
    });
  };

  return (
    <button onClick={handleClick} disabled={isPending}>
      👍 {likes} {isPending ? '...' : ''}
    </button>
  );
}

この構成により、以下のメリットが生まれます。

  • 記事一覧のデータ取得はサーバーで完結し、クライアントのバンドルサイズを圧迫しない。
  • インタラクティブなLikeButtonのみがClient Componentとなり、JavaScriptのロード量を最小限に抑える。
  • initialLikesをPropsで渡すことで、ハイドレーション後も初期状態が正しく表示される。

パターン2: Server ComponentをClient Componentに埋め込む (children props)

Client Componentの中に、静的なコンテンツとしてServer Componentを配置したい場合があります。これはchildren propsを活用することで実現できます。これにより、Client Componentの島(Island)の中に、サーバーでレンダリングされたコンテンツの穴(Hole)を作ることができます。

例: タブ切り替えUI(Client)と、各タブのコンテンツ(Server)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// components/TabContainer.tsx (Client Component)
"use client";

import { useState } from 'react';

export default function TabContainer({ tabs }) {
  const [activeTab, setActiveTab] = useState(0);

  return (
    <div>
      <div role="tablist">
        {tabs.map((tab, index) => (
          <button
            key={tab.label}
            role="tab"
            onClick={() => setActiveTab(index)}
          >
            {tab.label}
          </button>
        ))}
      </div>
      <div role="tabpanel">
        {/* childrenとして渡されたServer Componentがここに表示される */}
        {tabs[activeTab].content}
      </div>
    </div>
  );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// app/dashboard/page.tsx (Server Component)
import TabContainer from '@/components/TabContainer';
import UserProfile from '@/components/UserProfile'; // Server Component
import AnalyticsData from '@/components/AnalyticsData'; // Server Component

// これらのコンポーネントは重いデータフェッチを行うと仮定
async function UserProfile() { /* ... */ }
async function AnalyticsData() { /* ... */ }

export default function DashboardPage() {
  const tabs = [
    { label: 'Profile', content: <UserProfile /> },
    { label: 'Analytics', content: <AnalyticsData /> }
  ];

  return (
    <main>
      <h1>Dashboard</h1>
      {/* Client ComponentにServer ComponentをPropsとして渡す */}
      <TabContainer tabs={tabs} />
    </main>
  );
}

このパターンでは、タブの切り替えという状態管理はクライアントで行いつつ、各タブの中身はサーバーで完全にレンダリングされます。これにより、初期表示時に不要なタブのコンテンツまでクライアントに送られることがなくなり、パフォーマンスが向上します。

パターン3: クライアントサイドでのデータフェッチが依然として有効な場合

RSCが強力だからといって、クライアントサイドでのデータフェッチが完全になくなるわけではありません。SWRやTanStack Query (旧React Query) は、特定のユースケースにおいて依然として非常に有効です。

主なユースケース:

  • ユーザーの操作に頻繁に反応するデータ: 検索ボックスのサジェスト機能、無限スクロール、頻繁に更新されるダッシュボードなど。
  • クライアントの状態に依存するデータ: 認証状態(ログインしているユーザーの情報など)に基づいて取得するデータ。
  • 複雑なキャッシュ戦略やミューテーション管理: オプティミスティックUIアップデートなど、高度なUI/UXを実現したい場合。

RSCは初期ロードのパフォーマンス最適化に絶大な効果を発揮しますが、ロードのクライアント上でのリッチなデータインタラクションは、これらのライブラリが得意とする領域です。RSCとクライアントデータフェッチライブラリは、対立するものではなく、共存し補完し合う関係と捉えるのが正解です。

現場で使える実践的なTips

Tip 1: "use client"はできるだけ末端(Leaf)のコンポーネントに設定する

"use client"は境界線であり、一度宣言されるとその配下のすべてのコンポーネントがClient Componentになります。そのため、できるだけコンポーネントツリーの末端、つまり本当にインタラクティブ性が必要な最小単位のコンポーネントに設定することが重要です。

悪い例: ページ全体をClient Componentにしてしまう

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// app/dashboard/layout.tsx
"use client"; // ← アンチパターン!

import Header from '@/components/Header'; // Client Componentになる
import Sidebar from '@/components/Sidebar'; // Client Componentになる
import MainContent from '@/components/MainContent'; // Client Componentになる

export default function DashboardLayout({ children }) {
  // ...
}

良い例: インタラクティブな部分だけを分離する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// app/dashboard/layout.tsx (Server Component)
import UserMenu from '@/components/UserMenu'; // Client Component
import Sidebar from '@/components/Sidebar'; // Server Component
import MainContent from '@/components/MainContent'; // Server Component

export default function DashboardLayout({ children }) {
  return (
    <div>
      <header>
        <nav>...</nav>
        <UserMenu /> {/* インタラクティブな部分だけがClient Component */}
      </header>
      <Sidebar />
      <MainContent>{children}</MainContent>
    </div>
  );
}

Tip 2: Server Actionsでデータ更新をシンプルにする

フォーム送信やデータ更新(Mutation)のために、もはやAPIエンドポイントを自前で用意する必要はありません。Server Actionsを使えば、サーバーサイドで実行される関数をクライアントコンポーネントから直接、安全に呼び出せます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// app/actions.ts
"use server"; // ファイルの先頭に記述

import db from '@/lib/db';
import { revalidatePath } from 'next/cache';

export async function addPost(formData: FormData) {
  const title = formData.get('title') as string;
  
  await db.post.create({ data: { title } });

  // 関連するページのキャッシュをクリアして再描画をトリガー
  revalidatePath('/posts');
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// components/AddPostForm.tsx (Client Component)
"use client";

import { addPost } from '@/app/actions';

export default function AddPostForm() {
  return (
    <form action={addPost}>
      <input type="text" name="title" required />
      <button type="submit">Add Post</button>
    </form>
  );
}

formタグのaction属性に関数を渡すだけで、フォーム送信時にサーバー上のaddPost関数が実行されます。APIルートの作成、fetchの呼び出し、ローディング状態の管理といった定型的なコードが大幅に削減され、開発体験が飛躍的に向上します。

Tip 3: SuspenseとStreamingで体感速度を向上させる

ページの特定の部分でデータ取得に時間がかかる場合でも、Suspenseを使うことで他の部分の表示をブロックすることなく、UIを段階的に表示(Streaming)できます。

Next.jsでは、loading.tsxという規約ファイルを使うことで、これを簡単に実現できます。

例: 記事詳細ページとコメント欄 記事本体はすぐに表示したいが、コメントの読み込みには時間がかかるとします。

app/
└ posts/
  └ [slug]/
    ├ page.tsx      // 記事本体を表示するServer Component
    └ loading.tsx   // page.tsxのレンダリング中に表示されるUI
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// app/posts/[slug]/page.tsx
import PostContent from '@/components/PostContent';
import Comments from '@/components/Comments';
import { Suspense } from 'react';

export default function PostPage({ params }) {
  return (
    <div>
      {/* 記事本体はすぐにレンダリングされる */}
      <PostContent slug={params.slug} />

      {/* コメントはデータ取得が終わるまでフォールバックUIを表示 */}
      <Suspense fallback={<p>Loading comments...</p>}>
        <Comments slug={params.slug} />
      </Suspense>
    </div>
  );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// components/Comments.tsx (Server Component)
async function getComments(slug) {
  await new Promise(resolve => setTimeout(resolve, 2000)); // 意図的に遅延
  // ...
}

export default async function Comments({ slug }) {
  const comments = await getComments(slug);
  // ...
}

この構成により、ユーザーはまず記事本体を読み始めることができ、その間にコメントが非同期でロードされます。これにより、実際の読み込み時間が同じでも、体感速度は劇的に改善されます。

まとめ

React Server Componentsは、単なる新機能ではなく、Reactアプリケーションの設計思想そのものを変える、大きなパラダイムシフトです。

本記事の要点をまとめます。

  1. デフォルトはServer Component: すべてのコンポーネントはサーバーで実行されるものと考え、インタラクティブ性が必要な最小単位だけを"use client"でClient Componentに切り出す。
  2. データフェッチの主戦場はサーバーへ: async/awaitをServer Componentで直接使い、データソースの近くでデータを取得する。useEffectでのデータフェッチは、クライアントでのインタラクションに応じた動的な取得に限定される。
  3. "use client"は境界線: このディレクティブは、サーバーとクライアントの世界を分ける重要な役割を果たす。ツリーのなるべく末端に配置することを心がける。
  4. パターンを使い分ける: 「Propsでデータを渡す」「childrenでコンポーネントを埋め込む」といった基本的なパターンをマスターし、コンポーネントの責務を明確に分離する。
  5. エコシステムを賢く利用する: Server Actionsでミューテーションを簡略化し、SuspenseとStreamingでUXを向上させる。SWRやTanStack Queryも、依然としてクライアントサイドでの複雑なデータ管理に有効。

最初は戸惑うことも多いかもしれません。しかし、このサーバーとクライアントの新しい協調モデルを理解し、使いこなすことができれば、これまで以上に高速で、スケーラブルで、そして開発者体験の良いアプリケーションを構築できることは間違いありません。

さあ、今日からあなたのReact開発に、Server Componentsの力を取り入れてみませんか?