【テクニカル・上級編】 React Server ComponentsにおけるPropsのシリアライズ制約 – React実践ガイド

RSC時代におけるPropsのシリアライズ制約:なぜサーバーとクライアントの境界でデータが「壊れる」のか

こんにちは。日々、Reactのビルドサイズやレンダリングのライフサイクル、そしてV8エンジンのメモリヒープの動きに目を光らせているフロントエンド・アーキテクートの端くれです。

最近、React Server Components(以下、RSC)の波が本格的に押し寄せ、「サーバーサイドで動くコンポーネント」と「クライアントサイド(ブラウザ)で動くコンポーネント」の境界線を行き来する開発が日常になりました。

非常にエキサイティングなパラダイムシフトですが、コードレビューをしていて最も頻繁に遭遇し、かつアプリをクラッシュさせる魔物が潜んでいるのが、「サーバーからクライアントへPropsを渡す際のシリアライズ制約」です。

「あれ、このコンポーネントにハンドラー関数をそのまま渡したらエラーになった」
「クラスのインスタンスを渡したら、クライアント側でメソッドが消滅しているんだけど…」

そんな苦い経験はありませんか?
今回は、ブラウザとサーバーのネットワーク境界をまたぐときに何が起きているのか、その内部挙動とメモリの現実、そして私たちが実務で取るべき堅牢な回避策について、骨の髄まで解説していきましょう。

—

1. なぜ「関数」や「クラス」はサーバーから越境できないのか?

まず、大前提を整理しましょう。RSC(サーバー側)からClient Components(ブラウザ側)へPropsを渡すとき、Reactは内部でそのデータをJSONに似た特別なシリアライズ形式(Flightフォーマット)に変換し、ネットワーク(HTTPストリーム)経由でブラウザへ転送しています。

ここで、JavaScriptのプリミティブな現実を思い出してください。

関数(Function)はシリアライズ不能

関数は、単なるデータではありません。その関数が定義されたスコープ(クロージャ)の状態、変数の参照、そしてV8エンジンのメモリ上の機械語(JITコンパイルされたコード)へのポインタを内包しています。
これをネットワークの向こう側にある、まったく異なるプロセス(Node.jsやEdgeランタイムからブラウザのJSエンジン)へ「そのままコピーする」ことは、物理的に不可能です。サーバーのメモリ空間にあるポインタをブラウザに送っても、ブラウザ側にはそのメモリ領域が存在しないからです。

クラスインスタンス(Class Instance)の罠

「じゃあ、データ構造を保つためにクラスのインスタンスを渡そう」と考えたそこのあなた。それはさらに危険な罠です。
クラスのインスタンスをJSONやFlightフォーマットでシリアライズすると、何が起こるでしょうか? プロトタイプチェーンが完全に剥ぎ取られ、ただの「プレーンなオブジェクト(ただのハッシュマップ)」に成り果てます。
つまり、クラスに定義した便利なゲッターやメソッド、インスタンス固有の振る舞いはすべて消え去り、ただのデータ(JSON)だけがクライアントに届くことになります。ブラウザ側でそのメソッドを呼び出そうとした瞬間、`TypeError: not a function` の盛大な爆音が鳴り響くわけです。

—

2. ネットワーク境界をまたぐデータの構造とコスト

アーキテクトとして意識しなければならないのは、「サーバーからクライアントへ送るデータ量は、そのままネットワークのレイテンシと、クライアントのメインスレッドをブロックするJSONパースコストに直結する」という残酷な事実です。

RSCのPropsとして渡せるのは、厳密にシリアライズ可能なデータ(JSON-serializable data)に限られます。

  • プリミティブ型 (`string`, `number`, `boolean`, `null`, `undefined`)
  • プレーンなオブジェクト (`Object`)
  • 配列 (`Array`)
  • `Date` オブジェクト(Flightフォーマットで特別にサポートされているケースもありますが過信は禁物です)
  • `Map` や `Set`(一部のフレームワーク層でシリアライズ可能な形に変換される場合を除き、基本は注意が必要)

ここに巨大なデータ構造や、不要なネスト、さらにはORMのモデルインスタンス(データベースのコネクションやメタデータが隠しプロパティとしてぶら下がっている最悪の代物)をそのままPropsとして流し込もうものなら、メモリ効率は最悪になり、シリアライズ処理自体がCPUを圧迫します。

—

3. 実践:制約を回避し、堅牢な境界線を設計するパターン

では、これらの制約を踏まえ、実務でどのように設計すべきでしょうか。よくあるアンチパターンとその解決策をコードで見ていきます。

アンチパターン:サーバーからクライアントへコールバック関数を渡す

よくある間違いが、サーバーコンポーネント内で定義したイベントハンドラー(ミューテーション関数など)を、Client ComponentのPropsとして直接渡そうとするケースです。

// 【NGな例】サーバーコンポーネント
import ClientButton from ‘./ClientButton’;

export default async function BadServerComponent() {
// データベースを叩く関数
async function handleClick() {
‘use server’;
await db.user.update(…);
}

return (

{/ エラー!関数はシリアライズできないため、直接Propsとしては渡せない /}

);
}

正しいアプローチ:Server Actionsのインポートと境界の分離

サーバー側の関数(Server Actions)をクライアントコンポーネントで使いたい場合は、関数を直接Propsの引数としてシリアライズして渡すのではなく、その関数(`’use server’` が宣言されたモジュール)をクライアントコンポーネント側で直接インポートして呼び出すのが正しい設計です。

// 1. actions.ts (サーバーサイドのロジックを切り出す)
‘use server’;

export async function updateUserAction(userId: string) {
// 厳格なバリデーションとDB更新
await db.user.update({ where: { id: userId }, data: { … } });
}

// 2. ClientButton.tsx (クライアントコンポーネント)
‘use client’;

import { updateUserAction } from ‘./actions’;
import { useTransition } from ‘react’;

export default function ClientButton({ userId }: { userId: string }) {
const [isPending, startTransition] = useTransition();

return (

);
}

このアプローチであれば、サーバーからクライアントへ渡すPropsは `userId` という軽量なプリミティブ値(文字列)だけになり、シリアライズ制約を完璧にクリアできます。

—

データベースモデル(クラスインスタンスやORMオブジェクト)の浄化

データベースからフェッチしたオブジェクトを、そのままClient Componentに渡すのも重大なバグの元です。ORM(PrismaやDrizzleなど)のモデルは、内部に汚染されたプロパティやメソッドを持っていることがあります。

// 【NGな例】
const user = await db.user.findUnique({ where: { id } }); // クラス/特殊オブジェクトの可能性
return ;

これを防ぐためには、サーバー側で責任を持って「純粋なDTO(Data Transfer Object)」にマッピング(シリアライズ可能なプレーンなオブジェクトに変換)してからPropsに渡すという防衛的プログラミングが不可欠です。

// 【OKな例】
export default async function UserContainer({ id }: { id: string }) {
const rawUser = await db.user.findUnique({ where: { id } });

if (!rawUser) return

User not found

;

// 完全にプレーンなオブジェクト(DTO)に形を整える
const serializedUser = {
id: rawUser.id,
name: rawUser.name,
email: rawUser.email,
createdAt: rawUser.createdAt.toISOString(), // Date型も文字列に落とし込むと安全
};

return ;
}

ここまで徹底してデータ構造を制御して初めて、大規模プロダクトに耐えうる堅牢なRSCアーキテクチャが構築できます。

—

まとめ:境界を愛せよ

RSCにおけるPropsのシリアライズ制約は、一見すると足枷のように思えるかもしれません。しかし、これは「サーバーの肥大化した内部実装が、不用意にクライアントサイドへ漏れ出すのを防ぐための極めて強固な防壁」です。

サーバーはサーバーの領域(DB、秘匿情報、重い処理)に徹し、クライアントはクライアントの領域(UIのインタラクティブ性、ブラウザAPI)に徹する。その境界線を美しく引き、データを安全にトランスポートするための作法を理解しているエンジニアこそが、これからのReact開発をリードする真のスペシャリストと言えるでしょう。

明日のコードレビューでは、誰かがうっかり関数やORMのインスタンスをPropsに突っ込んでいないか、ぜひ鋭い目を光らせてみてください。それでは、良きアーキテクチャライフを!

コメント

タイトルとURLをコピーしました