🔎

意外と知らない React Server Components の真価 — ちゃんとコードを書いて確かめてみた

に公開

Server Componentsが「サーバーでHTMLを作ってクライアントに送る仕組み」なのは知ってる。でも、具体的に従来のReactと何がどう変わるのか、コードで見たことはありますか?

自分は「なんとなくサーバーで動くやつ」くらいの理解で使っていた。公式ドキュメントを読んで実際にコードを書いてみたら、バンドルサイズ、データ取得、ローディング体験が具体的にどう変わるのかが見えてきた。

この記事では、従来のやり方とServer Componentsを同じアプリで実装して比較した結果を書く。


Server Componentsの最初の価値:バンドルサイズ削減

ブログ記事のマークダウン変換を考える。従来のReactだと、変換ライブラリ(markedsanitize-html)をクライアントにまるごとダウンロードさせていた。表示するだけなのに。

"use client";
import { marked } from "marked";         // クライアントにダウンロードされる
import sanitizeHtml from "sanitize-html"; // クライアントにダウンロードされる

export default function PostPage() {
  const [post, setPost] = useState(null);
  
  useEffect(() => {
    fetch(`/api/posts/${id}`)
      .then(res => res.json())
      .then(data => setPost(data));
  }, []);
  
  const html = sanitizeHtml(marked(post.body));
  
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

marked(~40kB)とsanitize-html(~50kB)がブラウザに送られる。変換結果を表示するだけなのに、変換エンジンごと送っている。

Server Componentsなら、ライブラリはサーバーでだけ使われ、クライアントには変換済みHTMLだけが届く

import { marked } from "marked";         // クライアントには送られない
import sanitizeHtml from "sanitize-html"; // クライアントには送られない

export default async function PostPage({ params }) {
  const post = await db.posts.get(id);
  const html = sanitizeHtml(await marked(post.body));
  
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

実際にDevTools NetworkタブでJS転送量を比較した。

JS転送量
従来版("use client" + useEffect 839 kB
Server Components版 703 kB
-136 kB

136kBの差は、まさにmarkedsanitize-htmlがクライアントに送られなくなった分。

ちなみに、コンポーネントの判別方法はこう:

  • 何も書かない → Server Component(デフォルト)
  • "use client" → Client Component
  • "use server" はServer Componentのマークではない(Server Functionsのマーク。よくある誤解)

Server Componentsの真価:ウォーターフォール解消

マークダウン変換のような静的コンテンツはビルド時に生成できる。でも、IDによって中身が変わる動的なデータ(ブログ記事、ユーザープロフィールなど)はそうはいかない。

ブログ記事の詳細ページを考える。記事データと著者データが必要だが、記事を取得しないと著者のIDがわからない

ブラウザ → fetch('/api/posts/1')   → サーバー → 結果返す
ブラウザ → 記事を表示 → 著者IDがわかった!
ブラウザ → fetch('/api/authors/5') → サーバー → 結果返す
ブラウザ → やっと著者表示

2回のfetchが直列で実行される(=ウォーターフォール)。並列にできない。

useEffect(() => {
  fetch(`/api/posts/${id}`)                // 1回目
    .then(res => res.json())
    .then(post => {
      setPost(post);
      return fetch(`/api/authors/${post.authorId}`); // 2回目(直列!)
    })
    .then(res => res.json())
    .then(author => setAuthor(author));
}, [id]);

Server Componentsなら、サーバー内で全部完結する。

export default async function PostPage({ params }) {
  const post = await db.posts.get(id);
  const author = await db.authors.get(post.authorId);
  // 完成品がクライアントに届く
}
ブラウザからのfetch 所要時間
従来版 2回(直列) 約1.3秒(721ms + 629ms)
SC版 0回 サーバー内で完結

SC版のNetworkタブには、自分が書いたfetch1つもない?_rsc=...というNext.jsの内部リクエストが1回あるだけ。

ReactからDBに直接アクセスできるの?

従来のReactは全部ブラウザで動く。ブラウザからDB接続したら、接続情報がユーザーに丸見え。そもそもブラウザにはDBに接続する仕組みがない。だから間にAPIサーバーを挟む必要があった。

Server Componentsはサーバーで動く。だからDBに直接アクセスできるし、接続情報が漏れる心配もない。APIエンドポイントを自分で作る必要もなくなった。


「全部サーバーで待つと遅くない?」— Suspenseで解決する

さっきのブログ記事ページに、コメント機能を追加したとする。コメントの取得に2秒かかるなら、記事も含めて全部2秒待つのか?

答えは「分けられる」。

  • 記事は絶対必要awaitで待つ
  • コメントは後でいいawaitしないでSuspenseに任せる
export default async function PostPage({ params }) {
  const post = await db.posts.get(id);  // これは待つ

  return (
    <div>
      <article>{post.title}</article>
      {/* コメントは待たない → Suspenseが仮の画面を出す */}
      <Suspense fallback={<p>コメントを読み込み中...</p>}>
        <Comments postId={post.id} />
      </Suspense>
    </div>
  );
}

Commentsコンポーネント自体がasyncで、内部でawaitする:

export async function Comments({ postId }: { postId: number }) {
  const comments = await db.comments.getByPostId(postId);
  return (
    <ul>
      {comments.map(c => <li key={c.id}>{c.body}</li>)}
    </ul>
  );
}

コメント取得にわざと2秒の遅延を入れて確認した。

  • 記事本文・著者情報は即表示される
  • コメント部分だけ「コメントを読み込み中...」が出て、2秒後に差し替わる
  • ページ全体が真っ白にならない。準備できた部分から見せるストリーミング表示

従来のやり方だと、全データが揃うまで「読み込み中...」になるか、コンポーネントごとにuseEffect + useStateで個別管理するしかなかった。Suspenseなら宣言的に「ここだけ待たせる」が書ける


ただし、React単体では動かない

ここまでServer Componentsの良さを書いてきたが、大事な前提がある。Server Componentsの仕様はReactが定義したが、React単体では動かせない。

「このファイルはサーバーで実行する」「このファイルはクライアントに送る」という振り分けを、バンドラーがやる必要があるから。React自体はあくまで仕様(USBの規格みたいなもの)で、それを実際に動かすにはNext.jsのようなフレームワークが必要になる。

2026年3月時点の対応状況:

フレームワーク RSC対応
Next.js (App Router) 本番対応済み(唯一)
Waku 実験的対応(Viteベース)
Remix 未対応
素のReact (Vite等) 非対応

つまり、今のところServer Componentsを本番で使うなら実質Next.js一択。Reactコミュニティでは「ReactがVercel / Next.jsに依存しすぎでは」という声もある。ReactチームのメンバーがVercelに入社している背景もあり、この構造に対する議論は続いている。


まとめ

ポイント 従来のやり方 Server Components
ライブラリの転送 クライアントにダウンロード(839kB) サーバーで使用、完成品だけ送る(703kB)
データ取得 fetch 2回直列(約1.3秒) サーバー内で完結(fetch 0回)
APIエンドポイント 自分で作る必要あり 不要(DB直アクセス)
ローディング 全体が「読み込み中...」 Suspenseで部分表示

「サーバーで動くReact」くらいの理解で使っていたが、実際にコードを書いて比較してみると、何がどれだけ変わるのかが数値で見えた。136kBのバンドル削減、fetchが2回から0回、ページ全体のローディングが部分表示に。

「なんとなくServer Components使ってる」から「従来のやり方と何が違うか説明できる」になれたのが、今回一番の収穫だった。


参考:

Discussion