React Server Components徹底解剖:useEffectはもう要らない?
Next.js 13や14の登場とともに、Reactエコシステムには大きな地殻変動が起きています。単にフックが増えたとか、レンダリングが速くなったという話ではありません。私たちは今、React Server Components (RSC) という全く新しい時代の入り口に立っています。
正直なところ、混乱している方も多いのではないでしょうか? 「シリアライズ(Serialization)」に関する謎のエラーが出たり、「クライアント」と「サーバー」の境界線を意識させられたり、データの取得方法を一から学び直す必要があったり...。中でも最大の衝撃は、長年データ取得の定石だった useEffect が、突然「アンチパターン」のような扱いを受け始めたこと でしょう。
useEffect は死んだのでしょうか? いえ、そうではありません。しかし、データフェッチに関しては、もはや主役ではありません。
この記事では、表面的なRSCの使い方だけでなく、裏側で何が起きているのか(Flightプロトコルなど)、なぜこれが従来のSPAアーキテクチャの課題を解決する「銀の弾丸」となり得るのか、そして新しいメンタルモデルが大規模開発にどう効いてくるのかを、技術的に深掘りしていきます。
1. 従来の痛み:クライアントサイド・ウォーターフォール
RSCの必要性を理解するには、まず既存のSPA開発における「痛み」を直視する必要があります。
典型的なReactアプリを思い浮かべてください。ブラウザは巨大なJSバンドルをダウンロードし、Reactを起動し、コンポーネントツリーを構築します。そして最後に useEffect が走り出し、「あ、データを取ってこなきゃ」とAPIを叩きます。
// 見慣れたコードですよね function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); // 1. レンダリングが終わって初めてフェッチが走る useEffect(() => { fetch(`/api/users/${userId}`).then(data => { setUser(data); setLoading(false); }); }, [userId]); if (loading) return <Spinner />; return ( <div> <h1>{user.name}</h1> {/* 2. 親のロードが終わるまで、この子は存在すらしない */} <UserPosts userId={userId} /> </div> ); }
ここでの問題児は <UserPosts /> です。親である UserProfile のデータ取得とレンダリングが完了するまで、この子コンポーネントはマウントされず、自身のデータ取得を開始できません。
- JSロード
UserProfileレンダリング → APIコール- (待機...) → データ到着 → 再レンダリング
UserPostsマウント → APIコール- (また待機...) → データ到着 → 再レンダリング
これが ネットワーク・ウォーターフォール(Network Waterfall) です。滝のように処理が遅延していきます。ユーザーは次々と現れるローディングスピナーを見せられ続けることになります。
React QueryやSWRは状態管理を楽にしてくれましたが、根本的な制約は解決していません。「データ取得のロジックはコンポーネントにあるが、実行するのはあくまでクライアント(ブラウザ)である」 という点です。
2. React Server Components:サーバーをコンポーネント化する
RSCのアイデアはシンプルかつ強力です。「データがある場所(サーバー)で、コンポーネントをレンダリングしてしまえばいい」。
Server Componentはバックエンドに直接アクセスできます。APIエンドポイントを作る必要すらなく、直接DBを叩けます。そして、そのロジックを含んだJSコードをクライアントに送る必要もありません。
コードは劇的にシンプルになります:
// app/users/[id]/page.tsx import db from '@/lib/db'; // 1. asyncコンポーネント! async function UserProfile({ params }) { // 2. DB直接呼び出し。fetch('/api/...') は不要 const user = await db.user.findUnique({ where: { id: params.id } }); return ( <div> <h1>{user.name}</h1> <UserPosts userId={params.id} /> </div> ); } // 子供も非同期コンポーネント async function UserPosts({ userId }) { const posts = await db.post.findMany({ where: { authorId: userId } }); return ( <ul> {posts.map(post => <li key={post.id}>{post.title}</li>)} </ul> ); }
注目すべき変化:
useState,useEffectがない:状態管理も副作用も不要です。async/awaitが使える:トップレベルでawaitできます。
サーバーが UserProfile をレンダリングする際、DBへのアクセスが発生します。データが来れば即座に UserPosts の処理に移ります。これらはすべて データセンター内(サーバー内部) で完結するため、レイテンシは極小です。
3. 秘密のプロトコル:「Flight」
ここでよくある誤解が、「これってSSR(Server Side Rendering)と同じでしょ?」というものです。いいえ、違います。
SSRは完成されたHTMLを返します。対してRSCは、"Flight" と呼ばれる特殊なシリアライズ形式を返します。
ネットワークタブを見ると、以下のようなデータが流れてくるのが分かります:
1:I["./src/components/ClientCounter.js",["234","345"],"ClientCounter"] 0:["$","div",null,{"children":[["$","h1",null,{"children":"Hello World"}],["$","$L1",null,{}]]}]
これはReactツリーをテキスト化したものです。
- HTMLタグ(
div,h1)の情報 - 渡されたprops
- そして重要なのが、Client Componentを埋め込む場所への参照(Reference) です。
Server Componentのコード自体はブラウザに送信されません。
サーバー側で重い日付処理ライブラリを使っていても、クライアントに届くのは処理結果の文字列だけ。ライブラリのバンドルサイズはゼロになります。
4. 境界線:Server vs. Client
Server Componentはサーバーで動くため、ブラウザAPI(window, localStorage)やインタラクション(onClick, useState)は使えません。
ボタンやフォームなど、インタラクティブな要素が必要な場合は、"use client" ディレクティブを使って明示的に「クライアント側へオプトイン」する必要があります。
// src/components/LikeButton.tsx 'use client'; // 👈 魔法の言葉 import { useState } from 'react'; export default function LikeButton() { const [likes, setLikes] = useState(0); return <button onClick={() => setLikes(likes + 1)}>いいね {likes}</button>; }
誤解しないでほしいのは、"use client" を付けたからといって、昔ながらのCSR(クライアントサイドレンダリング)だけになるわけではないということです。これらもサーバー上で初期HTMLとしてレンダリング(SSR)されます。単に「ハイドレーション(Hydration)のためにJSバンドルをブラウザに送れ」という指示に過ぎません。
コンポーネント合成(Composition)の妙
「ルートレイアウトにContext Providerを置くために "use client" を書いたら、配下すべてのコンポーネントがクライアントコンポーネントになってしまうのでは?」
そう心配する方もいますが、childrenパターン を使えば回避できます。
// app/page.tsx (Server Component) import ClientLayout from './ClientLayout'; import ServerPostList from './ServerPostList'; export default function Page() { return ( // Server Component を Client Component の children として渡す <ClientLayout> <ServerPostList /> </ClientLayout> ); }
// app/ClientLayout.tsx (Client Component) 'use client'; export default function ClientLayout({ children }) { // ここに来る children は、すでにサーバーでレンダリング済みの「スロット」です。 // ClientLayout は中身のロジックを知らなくても描画できます。 return <div className="theme-provider">{children}</div>; }
このパターンこそが、RSCアーキテクチャの肝です。静的・重い処理はサーバーに残し(ServerPostList)、必要な部分だけをクライアントで動かす(ClientLayout)ことができるのです。
5. 結論:useEffectにさよならを
私たちがこれまで useEffect でデータを取得していたのは、Reactのレンダリングフローの中で他に副作用(Side Effect)を実行できる場所がなかったからです。仕方なく使っていたのです。
しかし、その代償は大きすぎました。
- 遅い(Paint後に実行される)
- バグりやすい(競合状態、メモリリーク、Strict Modeでの2回実行...)
RSCでは、レンダリング 中 にデータを待ちます(await)。
非常に素直な同期的なメンタルモデルです。
もちろん、WebSocketやブラウザイベントの購読には依然として useEffect が必要です。しかし、「ページを開いた瞬間のデータ取得」という用途においては、その役割を終えました。
Next.js App Routerへの移行に戸惑っている方へ。
これは単なるフレームワークの更新ではなく、「クライアントで無理やりサーバーの真似事をしていた時代」からの脱却 だと考えてみてください。
次に useEffect を書こうとしたとき、一瞬手を止めて考えてみましょう。
「これ... サーバーで await 一行で済む話じゃないか?」
移行のヒント:一気にすべてを書き換えようとせず、ボタンや入力フォームなどの末端(Leaf)コンポーネントから "use client" 化していき、ページ構成要素はServer Componentとして残すアプローチが最もスムーズです。