【実務・中級編】 React Server ComponentsにおけるPropsのシリアライズ制約 – React実践ガイド

やあ、調子はどうだい?
最近、現場でもすっかりお馴染みになってきた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 (

);
}

—

チーフアーキテクトからの実践的なアドバイス

実務でコードを書くときは、脳内に「シリアライズ・フィルター」を常駐させておくといい。

1. 「これ、JSONに変換できるか?」と自問する
Server ComponentからClient ComponentへPropsを渡す瞬間、手元のコードで `JSON.parse(JSON.stringify(props))` を脳内で実行してみるんだ。そこでエラーになりそうなもの(関数、クラス、Undefinedを許容しない構造など)が含まれていたら、その設計は赤信号だ。
2. 日付(Date)や特殊なオブジェクトの扱いに注意する
`Date` オブジェクトは一見シリアライズできるように思えるが、境界を越えた瞬間に文字列(String)に変換される。クライアント側で `.getTime()` とか生やしてると突然型エラーで爆発するので、サーバー側であらかじめ `.toISOString()` に変換しておくのがプロの技だ。
3. 境界線を意識したコンポーネント設計
「どこまでがサーバーで、どこからがクライアントか」。この境界線を曖昧にせず、データを加工するレイヤー(サーバー)と、描画・インタラクションを担うレイヤー(クライアント)をきっちり分離しよう。

RSCは最初はとっつきにくく感じるかもしれないけれど、この「シリアライズ制約」という制約事項こそが、アプリケーションのパフォーマンスとセキュリティを劇的に引き上げる最高のスパイスなんだ。

さあ、今日の学びを活かして、無駄なエラーとはおさらばした美しいコードを書きにいこうぜ!

コメント

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