やあ、調子はどうだい?
最近、現場でもすっかりお馴染みになってきたReact Server Components(RSC)だけど、コードレビューをしていて一番やりがちな「やらかしポイント」って知ってるかい?
そう、「サーバーからクライアントへ、うっかり関数やクラスのインスタンスをPropsとして渡しちまう」という罠だ。
「え、今まで普通に親から子へハンドラー関数とか渡してたじゃん?」と思ったそこの君。
それ、従来の「全部クライアントサイドで動くReact(Client Components)」の感覚のまま止まってる証拠だ。RSCの世界に足を踏み入れたなら、僕らは「サーバーとクライアントの境界線(Network Boundary)」という厳格な物理法則を意識しなきゃいけない。
今日は、この「Propsのシリアライズ制約」の裏側で何が起きているのか、そして現場でどうやってこの制約をスマートに回避するのか、チーフアーキテクトの僕が徹底的に叩き込んでやろう。
—
なぜ、関数やクラスインスタンスは渡せないのか?
結論から言おう。
サーバー(Node.jsやEdgeワーカー)とクライアント(ブラウザ)の間にあるのは、広大な「ネットワーク」だからだ。
RSCアーキテクチャでは、サーバー側で実行されたコンポーネントのJSX(正確にはReactの内部表現であるJSONライクなストリーム)が、シリアライズ(直列化)されてHTTPレスポンスとしてブラウザにぶっ飛んでいく。
ここで思い出してほしい。JavaScriptの`JSON.stringify()`の挙動を。
- 関数(Function)? → 無視されるかエラーになる。
- クラスのインスタンス(`new MyClass()`)? → プロトタイプチェーンやメソッドは綺麗に削ぎ落とされ、ただの無機質な平坦なオブジェクト(POJO: Plain Old JavaScript Object)に成り下がる。もしくはシリアライズ不可能として爆発する。
ブラウザの画面側で動くインタラクティブなClient Component(`”use client”`のあいつらだ)は、「サーバーで生成されたデータのSnapshot」を受け取ることはできても、サーバーのメモリ空間にある関数やインスタンスの「実体」に直接アクセスすることは物理的に不可能なんだ。
[Server Component] ──(シリアライズ: JSON)──> [Network] ──> [Client Component]
❌ 関数やクラスは消滅する
これが、ReactがPropsに対して課している厳格なシリアライズ制約の正体だ。
—
現場でよくある「やらかしパターン」と現実的な回避策
さて、ここからが実務の話だ。
例えば、「ユーザー情報を取得してカードに表示する。ついでにボタンをクリックした時のイベントハンドラーもサーバー側から渡したいぜ!」なんてコードを書いていないかい?
やってはいけないアンチパターンと、それを華麗に回避するベストプラクティスをコードで見ていこう。
❌ やってはいけない実装(シリアライズエラーの温床)
// サーバーコンポーネント (app/user/[id]/page.tsx)
import { fetchUserFromDB } from ‘@/lib/db’;
import UserCard from ‘./UserCard’; // これが “use client” だとする
export default async function Page({ params }: { params: { id: string } }) {
const user = await fetchUserFromDB(params.id);
// 【大罪】DBから取ってきたクラスインスタンスや、
// ここで定義した関数をそのままClient Componentに渡そうとしている!
const handleClick = () => {
console.log(‘Clicked!’);
};
return (
);
}
このコードをブラウザで動かそうもんなら、Next.jsなどのコンソールに「Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with ‘use server’.」といったお馴染みの絶望メッセージが表示されるはずだ。
—
⭕ 正しい回避策:プリミティブなデータとServer Actionsの活用
じゃあ、どうすればいいのか?
解決策は大きく分けて2つある。
1. データは「プレーンなオブジェクト(JSONで表現できる型)」だけを渡す。
2. 関数を渡したい場合は、`”use server”`(Server Actions)を使うか、イベントハンドラーはクライアント側で完結させる。
実務でそのまま使える、洗練されたコードを見てみよう。
1. データのシリアライズ(プレーンなオブジェクトへの変換)
データベースのORM(PrismaやDrizzleなど)を使っていると、モデルのインスタンスが返ってきがちだ。これは必ずシリアライズ可能なプレーンなオブジェクト(DTO: Data Transfer Object)に変換して渡そう。
2. イベントやアクションの処理
ボタンクリックなどのインタラクションは、基本的にClient Component側でハンドリングするか、サーバーサイドで処理したい場合はServer Actionsとして関数を切り出す。
// ==========================================
// サーバーコンポーネント (app/user/[id]/page.tsx)
// ==========================================
import { db } from ‘@/lib/db’;
import { UserCard } from ‘./UserCard’;
import { updateUserStatus } from ‘@/actions/userActions’; // Server Action
export default async function Page({ params }: { params: { id: string } }) {
// 1. DBからデータを取得
const rawUser = await db.user.findUnique({ where: { id: params.id } });
if (!rawUser) {
return
;
}
// 2. クライアントに渡すために、必要なデータだけを抽出したプレーンなオブジェクト(DTO)を作る
// Date型はシリアライズ時に文字列化されるので、ISOストリングにしておくと安全
const serializedUser = {
id: rawUser.id,
name: rawUser.name,
email: rawUser.email,
updatedAt: rawUser.updatedAt.toISOString(),
};
return (
// serializedUser は純粋なJSONなので、ネットワーク境界を無事に通過できる
// サーバーサイドの処理を実行したい場合は、Server Actionを関数としてではなく「アクション」として渡すか、
// もしくはClient Component側でイベントを定義する
);
}
// ==========================================
// クライアントコンポーネント (app/user/[id]/UserCard.tsx)
// ==========================================
‘use client’;
import { useState } from ‘react’;
// サーバーから渡されるプロパティの型定義
type UserDTO = {
id: string;
name: string;
email: string;
updatedAt: string;
};
type UserCardProps = {
user: UserDTO;
// Server Actionは例外的に、PropsとしてClient Componentに渡すことが許されている
updateAction: (userId: string, newName: string) => Promise
};
export function UserCard({ user, updateAction }: UserCardProps) {
const [name, setName] = useState(user.name);
const [isPending, setIsPending] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsPending(true);
try {
// サーバーアクションを呼び出す
await updateAction(user.id, name);
} catch (error) {
console.error(‘更新に失敗しました’, error);
} finally {
setIsPending(false);
}
};
return (
{name}
{user.email}
最終更新: {user.updatedAt}
);
}
—
チーフアーキテクトからの実践的なアドバイス
実務でコードを書くときは、脳内に「シリアライズ・フィルター」を常駐させておくといい。
1. 「これ、JSONに変換できるか?」と自問する
Server ComponentからClient ComponentへPropsを渡す瞬間、手元のコードで `JSON.parse(JSON.stringify(props))` を脳内で実行してみるんだ。そこでエラーになりそうなもの(関数、クラス、Undefinedを許容しない構造など)が含まれていたら、その設計は赤信号だ。
2. 日付(Date)や特殊なオブジェクトの扱いに注意する
`Date` オブジェクトは一見シリアライズできるように思えるが、境界を越えた瞬間に文字列(String)に変換される。クライアント側で `.getTime()` とか生やしてると突然型エラーで爆発するので、サーバー側であらかじめ `.toISOString()` に変換しておくのがプロの技だ。
3. 境界線を意識したコンポーネント設計
「どこまでがサーバーで、どこからがクライアントか」。この境界線を曖昧にせず、データを加工するレイヤー(サーバー)と、描画・インタラクションを担うレイヤー(クライアント)をきっちり分離しよう。
RSCは最初はとっつきにくく感じるかもしれないけれど、この「シリアライズ制約」という制約事項こそが、アプリケーションのパフォーマンスとセキュリティを劇的に引き上げる最高のスパイスなんだ。
さあ、今日の学びを活かして、無駄なエラーとはおさらばした美しいコードを書きにいこうぜ!

コメント