この記事を読んでくださり、ありがとうございます。
FEを実装するようになり、React、Next.js、TypeScriptを扱うようになりました。
FEなのにクライアントとサーバーの2種類があるとのことで、BEとは明らかに異なる概念への理解が難しいと感じています。
と、いうわけで同じような場面に出くわす人が多いであろう、RCCとRSCの違いをまとめてみます。
React Server Component (RSC) と React Client Component (RCC) の違い
RSCとは
- サーバーでレンダリングされるコンポーネント
- データ取得やHTMLの組み立てを担当し、クライアントに効率的に送信する
- 副作用やブラウザ依存の処理は持たない
- StreamingやSuspenseと組み合わせることでUXを改善できる
- skelton uiはSuspenseを使えばサッと実装可能
RCCとは
- ブラウザで動作するコンポーネント
- 状態管理やイベントハンドリング、副作用を伴う処理など、クライアントでしかできない処理を担当する
設計ガイドライン
基本方針
- できるだけRSCに寄せることで、クライアント側のJavaScriptバンドルサイズを削減し、パフォーマンスを向上させる
- RCCはブラウザでしかできない処理に限定する
RSCの責務
- サーバーでデータ取得(DBクエリ、API呼び出し)
- HTMLの組み立てと初期状態の生成
- Streaming + Suspenseで部分的にレンダリングを先行
- 副作用やブラウザ依存の処理は持たない
RCCの責務
- 状態管理(
useState) - 副作用(
useEffect) - ユーザー操作に応じたUI更新
- DOM操作やイベントハンドリング
例外的な設計パターン
基本的にはRSCで担当しますが、要件によってはRCCで処理する方が適切な場合があります。
今回はそのうち1つを経験したこともあり、ご紹介します。
大容量データの扱い
ファイルのバイナリデータや動画などの大容量データをサーバー側で直接扱うと、メモリ圧迫やネットワーク負荷が発生します。
この場合、RSCはキー(ハッシュ値やID)やメタ情報のみを管理し、RCC側で必要なタイミングで大容量データを取得・処理する設計が望ましいです。
ユースケース例
- 画像編集アプリでは、RSCは画像IDやメタ情報を返し、RCCで画像バイナリをロードして表示する
- 動画プレイヤーでは、RSCは動画のURLやハッシュを返し、RCCでストリーミング再生する
- ファイルアップロードでは、RSCはアップロード済みファイルのキーを管理し、RCCでプレビューやダウンロードする
コード例で見る分離の実装
次の例は Next.js の App Router を前提にしています。
RSCがデータ取得と静的UIを担当し、RCCがインタラクションと状態管理を担当します。
サーバーでデータを取得して表示するページ
app/page.tsx はデフォルトでサーバーコンポーネントです。
// app/page.tsx (RSC)
import { Suspense } from 'react';
import { getArticles } from '@/lib/data';
import ClientFilter from '@/components/ClientFilter';
export default async function Page() {
const articles = await getArticles(); // DBやAPIから取得(RSCでOK)
return (
<main>
<h1>記事一覧</h1>
{/* Streaming と Suspense を活用(RSC側でOK) */}
<Suspense fallback={<p>読み込み中...</p>}>
<ArticleList articles={articles} />
</Suspense>
{/* フィルタのインタラクションは RCC に委譲 */}
<ClientFilter initialArticles={articles} />
</main>
);
}
function ArticleList({ articles }: { articles: { id: string; title: string }[] }) {
return (
<ul>
{articles.map((a) => (
<li key={a.id}>{a.title}</li>
))}
</ul>
);
}
クライアントで状態とイベントを扱うフィルタ
components/ClientFilter.tsx はクライアントコンポーネントです。
// components/ClientFilter.tsx (RCC)
'use client';
import { useMemo, useState } from 'react';
type Article = { id: string; title: string };
export default function ClientFilter({ initialArticles }: { initialArticles: Article[] }) {
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? initialArticles.filter(a => a.title.toLowerCase().includes(q)) : initialArticles;
}, [query, initialArticles]);
return (
<section>
<label>
検索
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="タイトルで検索"
/>
</label>
<ul>
{filtered.map(a => <li key={a.id}>{a.title}</li>)}
</ul>
</section>
);
}
サーバーアクションで作成を行い、結果をRCCで扱う
RSCはアクションでDBを書き込み、RCCはフォームのUXを扱います。
// app/actions.ts (RSCのサーバーアクション)
'use server'; // 明示的に使ってます
import { revalidatePath } from 'next/cache';
import { createArticle } from '@/lib/data';
export async function createArticleAction(formData: FormData) {
const title = String(formData.get('title') ?? '');
if (!title) {
throw new Error('タイトルは必須です');
}
const id = await createArticle({ title });
revalidatePath('/'); // ページを再検証
return { id, title };
}
// components/ClientCreateForm.tsx (RCC)
'use client';
import { useState } from 'react';
import { createArticleAction } from '@/app/actions';
export default function ClientCreateForm() {
const [pending, setPending] = useState(false);
const [message, setMessage] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
setMessage(null);
const formData = new FormData(e.currentTarget);
try {
const res = await createArticleAction(formData);
setMessage(`作成しました ID=${res.id}`);
e.currentTarget.reset();
} catch (err) {
setMessage('作成に失敗しました');
} finally {
setPending(false);
}
}
return (
<form onSubmit={onSubmit}>
<label>
タイトル
<input name="title" placeholder="記事タイトル" />
</label>
<button type="submit" disabled={pending}>
{pending ? '送信中' : '作成'}
</button>
{message && <p>{message}</p>}
</form>
);
}
大容量データはキーで管理し、RCCで取得する例
RSCはハッシュやIDを返すだけにして、RCCでバイナリを取得します。
// app/files/[id]/page.tsx (RSC)
import { getFileMetaById } from '@/lib/files';
import FileViewer from '@/components/FileViewer';
export default async function FilePage({ params }: { params: { id: string } }) {
const meta = await getFileMetaById(params.id);
// meta には { id, name, hash, mimeType } などを含める
return (
<main>
<h1>ファイル表示</h1>
{/* バイナリは読み込まず、キーだけ渡す */}
<FileViewer fileId={meta.id} fileHash={meta.hash} mimeType={meta.mimeType} />
</main>
);
}
// components/FileViewer.tsx (RCC)
'use client';
import { useEffect, useState } from 'react';
type Props = {
fileId: string;
fileHash: string; // 署名や検証用に使える
mimeType: string;
};
export default function FileViewer({ fileId, fileHash, mimeType }: Props) {
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let revoked = false;
async function load() {
setError(null);
try {
// クライアントでのみ大容量を取得(CDNや署名付きURLを想定)
const res = await fetch(`/api/files/${fileId}`); // 署名検証や認可はAPI側で
if (!res.ok) throw new Error('取得に失敗しました');
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
if (!revoked) setUrl(objectUrl);
} catch (e) {
setError('読み込みに失敗しました');
}
}
load();
return () => {
revoked = true;
if (url) URL.revokeObjectURL(url);
};
}, [fileId]);
if (error) return <p>{error}</p>;
if (!url) return <p>読み込み中...</p>;
if (mimeType.startsWith('image/')) {
return <img src={url} alt={fileHash} style={{ maxWidth: '100%' }} />;
}
if (mimeType === 'application/pdf') {
return <iframe src={url} title="PDF" style={{ width: '100%', height: 600 }} />;
}
return <a href={url} download>ダウンロード</a>;
}
APIルートでバイナリを返す例
サーバー側ではストリーミングや範囲リクエスト対応を検討します。
// app/api/files/[id]/route.ts (EdgeでなくNode runtimeを想定)
import { NextRequest } from 'next/server';
import { getFileStreamById } from '@/lib/files';
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
const { stream, mimeType, size } = await getFileStreamById(params.id);
return new Response(stream, {
headers: {
'Content-Type': mimeType,
'Content-Length': String(size),
// 必要に応じてキャッシュや範囲リクエスト対応ヘッダを設定
},
});
}
メリット
- パフォーマンス向上により初期表示が速くなる
- サイトの表示に際して、すでにデータのある状態でクローラーが徘徊することになるため、SEOに強くなる
- クライアント側のバンドルが小さくなり、体感速度が上がる
- 責務分離によりコードの見通しが良くなる
- 大容量データの取り扱いでも、サーバーのメモリ消費を抑えられる
まとめ
RSCはデータ取得と静的UIを、RCCはインタラクションと状態管理を担うのが基本です。
ただし大容量データのようなケースでは、RSCはキーやメタ情報だけを扱い、RCCが実データを取得する方針が有効です。
この分離を意識することで、読みやすく保守しやすいフロントエンドを作れます。