Zod Codecs で 'use client' を介した Server-Client 間データ転送を強化する
'use client'
は、サーバーからクライアントへと、描画用データを送信する境界を表すマーカーである ということは、広く知られています。
上記の記事の中では軽く触れるだけにとどめましたが、実は、 'use client'
は「素の JSON」よりも多種多様なデータの転送に対応しています。
RSC が「サーバー側 / ブラウザ側 のコードをシームレスに繋ぎ合わせる」技術であることが良くわかりますね。
サーバコンポーネントからクライアントコンポーネントに渡される props の値は、シリアライズ可能 (serializable) である必要があります。
シリアライズ可能な props には以下のものがあります:
- プリミティブ
- シリアライズ可能な値を含んだ Iterable
- Date
- プレーンなオブジェクト: オブジェクト初期化子で作成され、シリアライズ可能なプロパティを持つもの
- サーバアクション (server action) としての関数
- クライアントまたはサーバコンポーネントの要素(JSX)
- プロミス
出典:
'use client'
だけで Date を送れる
先ほど述べた通り、 'use client'
でマークされた Client Component に、Server Component から Date オブジェクトを渡すことは可能です。
以下の例では、 Date
をサーバーからクライアントに送る口実として、「サーバー側で現在時刻を取得して、ブラウザ側でそれを端末のタイムゾーンに基づいて表示する」という、Client Component 抜きでは実装しづらい機能を実装しています。
実際の画面の様子: 世界協定時の時刻と、PDT(ブラウザ側のタイムゾーンであるサンフランシスコの現地時刻)が表示されている
▼コンポーネント間でデータが受け渡される様子の概略図
// import 一部省略
import { LocaleAwareDate } from "./locale-aware-date";
// 中略
export default async function Page({}: PageProps<"/rsc-date/date">) {
await connection();
return (
<div className={styles.container}>
{/* 中略 */}
<section>
<h2>アクセスした時刻:</h2>
<LocaleAwareDate value={new Date()} />
</section>
</div>
);
};
"use client";
// import 省略
/** ブラウザ側のタイムゾーンでの日時を表示する */
const formatter = new Intl.DateTimeFormat(undefined, {
/* 中略 */
});
type Props = {
value: Date;
};
export function LocaleAwareDate({ value }: Props) {
// SSR 時: undefined
// CSR 時には、ブラウザ側のロケールで表示
const localDate = useSyncExternalStore(
() => () => {},
() => formatter.format(value),
() => undefined
);
return (
<div className={styles.root}>
{/* 中略 */}
<div>
<span className={styles.prefix}>ブラウザ側タイムゾーンでの時刻: </span>
<span className={styles.time}>{localDate}</span>
</div>
</div>
);
}
ソースコード全文
import { type Metadata } from "next";
import { connection } from "next/server";
import Link from "next/link";
import { LocaleAwareDate } from "./locale-aware-date";
import styles from "./page.module.scss";
export const metadata: Metadata = {
title: "Date の振る舞いテスト",
};
export default async function Page({}: PageProps<"/rsc-date/date">) {
await connection();
return (
<div className={styles.container}>
<nav className={styles.nav}>
<Link href="/rsc-date" className={styles.navLink}>
← Back to RSC Date Examples
</Link>
</nav>
<h1>現地時刻の振る舞いテスト</h1>
<section>
<h2>アクセスした時刻:</h2>
<LocaleAwareDate value={new Date()} />
</section>
</div>
);
}
"use client";
import { useSyncExternalStore } from "react";
import styles from "./locale-aware-date.module.scss";
/**
* ブラウザ側のタイムゾーンでの日時を表示する
*/
const formatter = new Intl.DateTimeFormat(undefined, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZoneName: "short",
});
type Props = {
value: Date;
};
export function LocaleAwareDate({ value }: Props) {
// SSR 時: undefined
// CSR 時には、ブラウザ側のロケールで表示
const localDate = useSyncExternalStore(
() => () => {},
() => formatter.format(value),
() => undefined
);
// 下は Hydration Error になるのでダメ!
// const localDate = formatter.format(value);
return (
<div className={styles.root}>
<div>
<span className={styles.prefix}>ISO: </span>
<time className={styles.time}>{value.toISOString()}</time>
</div>
<div>
<span className={styles.prefix}>ブラウザ側タイムゾーンでの時刻: </span>
<span className={styles.time}>{localDate}</span>
</div>
</div>
);
}
Zod Codecs と組み合わせれば Temporal も送れる
Temporal の日付オブジェクトはどうでしょうか?ここでは Temporal.PlainDateTime
型で試してみましょう。
(少なくとも)2025/09/24 現在では、 'use client'
でシリアライズ可能なリストに列挙されておらず、そのままでは送信できません。
無理やり渡そうとすると、このようなエラーが表示されます。
(page.tsx
は Server Component で、ShowPlainDateTime
コンポーネントには 'use client'
ディレクティブを設定しています。)
しかし、これで終わりではありません。
シリアライズ可能なデータに変換して送信する
- Server Component 側では、《
'use client'
でシリアライズ可能なデータ型》に変換して、Props に渡す。- wrap
- Client Component 側では、それを受け取って、元の型に復元する
- unwrap
という少しの手間を掛けさえすれば、とりあえず対応できます。
とりあえずこれで目的は果たせますが、しかし、実装するとなると、なんかゴチャッとして使いにくいユーティリティーになる匂いがします。
JavaScript/TypeScript は名前空間機能がなく、関数の名前は長〜〜い動詞句になってしまい、いろいろな情報を提供しようとすると、それぞれバラバラな名前になってしまうので、こんな感じになってしまいます。
// Server Component 側: PlainDateTime -> string
import { wrapPlainDateTime } from "#/utils/react/sharable/plain-date-time";
// Client Component 側: string -> PlainDateTime
import { unwrapIntoPlainDateTime } from "#/utils/react/sharable/plain-date-time";
// 型の情報を SSoT (信頼できる唯一の情報源)として提供したい
import {
type PlainDateTime,
type PlainDateTimeUnderlying, // つまり string
} from "#/utils/react/codecs/plain-date-time";
import 自動補完が全く役に立たない訳ではないが…
「動詞が最初に来て、その後に目的語が来る」このパターンは、UI デザインの文脈では「タスク指向 UI」、つまりユーザーに「作る側の都合」を押し付ける、悪い UI の設計とされるでしょう。
(対義語は「オブジェクト指向 UI」(OOUI)で、作り手の都合を廃して、ユーザーの脳内のイメージに合わせて作る、理想的な設計を指しています。)
ユーティリティ関数のたぐいも、実装者である同僚 etc. をユーザーと考えると一種の UI なので、「よい UI」を目指せるなら目指したいですよね?
SSoT オブジェクトに全てを集約する
では OOUI 的にするために、1つのオブジェクトに情報を集約して、その SSoT(信頼できる唯一の情報源)から情報を引き出すためのユーティリティと組み合わせて利用できるようにしてみましょう。
(ツリーシェイキングは犠牲になりますが、それほど大きなコード量にはならないので妥協することにします。)
export type Sharable<in out S, in out U> = {
wrap: (s: S) => U;
unwrap: (u: U) => S;
};
export type ShapedType<C> = C extends Codec<infer S, infer _> ? S : never;
export type UnderlyingType<C> = C extends Codec<infer _, infer U>
? U
: never;
import { Temporal } from "temporal-polyfill";
import type { Sharable } from "./_sharable";
export const SharablePlainDateTime: Sharable<Temporal.PlainDateTime, string> = {
wrap: (dateTime) => dateTime.toString(),
unwrap: (string) => Temporal.PlainDateTime.from(string),
}
import { type ShapedType, type UnderlyingType } "#/utils/react/sharable";
import { SharablePlainDateTime } from "#/utils/react/sharable/plain-date-time";
// Server Component 側: PlainDateTime -> string
SharablePlainDateTime.wrap(plainDateTime);
// Client Component 側: string -> PlainDateTime
SharablePlainDateTime.unwrap(underlying);
// sharable ユーティリティを使って、SSoT としての Codec オブジェクトから型情報を抽出できる
type Props = {
now: UnderlyingType<typeof SharablePlainDateTime>,
};
type PlainDateTime = ShapedType<typeof SharablePlainDateTime>;
こうすれば、
- wrap 関数
- unwrap 関数
- Shaped 型の情報
- Underlying 型の情報
が全て、SSoT たる PlainDateTimeCodec
オブジェクトから派生的に得られるようになっています。これはかなり OOUI 的でわかりやすいと思います。
"#/utils/react/sharable"
ユーティリティの使い方をユーザーが知っておく必要があるのは不便に思われるかもしれませんが、情報がうまく集約されたことと比べれば、必要な学習コストだと割り切っても良いでしょう。
Zod Codecs のパターンに乗っかる
上記では、お手製の Codec
ユーティリティを使って、シリアライゼーションのための双方向の型変換にパターンを規定してあげて、取り扱いを容易にしました。
しかし、実は、このパターンを提供してくれるようになったライブラリがあります。
それが Zod です。
Zod は v4.1 から、Codecs という双方向の型変換をサポートする仕組みを提供しています。(従来のスキーマは、単一方向の型変換のみサポートしていました。)
この Codec の仕組みは、そのまま今回のユーティリティを実装するための「型枠」として採用できます。
z.codec(in_, out, { decode, encode })
自前実装の Sharable ユーティリティと、codec の機能の対応関係は、以下のようになります。
Sharable | codec の引数・オプション |
---|---|
Shaped | _in |
Underlying | out |
wrap | decode |
unwrap | encode |
unwrap と encode みたいなところが捻れてる感じで難しいですが、Codec の設計思想に合わせるには、こうする必要があります。
brand を使う
Zod には、brand という、BrandedType パターンを利用するための機能も用意されています。
Underlying (out
) に brand を使ったスキーマを設定してあげれば、unwrap
するときに、「wrap
を経たデータ」のみを受け付けて、生の文字列を排除できるようになります。
export const SharablePlainDateTime = sharable(
z.instanceof(Temporal.PlainDateTime),
z.string().brand("SharablePlainDateTime"), // ここで brand を使う
{
wrap: (dateTime) => dateTime.toString(),
unwrap: (string) => Temporal.PlainDateTime.from(string),
}
);
import { SharablePlainDateTime } from "./_sharable-plain-date-time";
import {
unwrap,
type ShapedType,
type UnderlyingType,
} from "./_utils/share-value-by-codec";
// ✅️ 正しい使い方
type Props = {
nowPlainDateTime: UnderlyingType<typeof SharablePlainDateTime>;
};
export function ShowPlainDateTime({
nowPlainDateTime: _nowPlainDateTime,
}: Props) {
const nowPlainDateTime = unwrap(SharablePlainDateTime, _nowPlainDateTime);
// 🔴 生のデータは排除してくれる
const _1 = unwrap(SharablePlainDateTime, "Raw String");
// ^^^^^^^^^^^^
// コンパイルエラー: 型 'string' の引数を型 'string & $brand<"SharablePlainDateTime">' のパラメーターに割り当てることはできません。
}
この BrandedType パターンによる防御は、ケアレスミスの防止に大きく寄与することが期待できます。
完成品。ブラウザ側のタイムゾーン(「サンフランシスコ」に設定されている)に左右されず、アクセス時のサーバー内部(JST)の現在時刻を表示できている。
比較用: Date の例では、世界協定時の時刻と、PDT(ブラウザ側のタイムゾーンであるサンフランシスコの現地時刻)を表示していた
ソースコード
以下に、ソースコードの配置およびその本文を示します。(CSS Module ファイルについては、省略)
src/app/rsc-date/temporal/
├── _utils/
│ └── share-value-by-codec.ts # Codec ユーティリティ
├── _sharable-plain-date-time.ts # Zod Codec の定義
├── page.tsx # ページコンポーネント(Server)
├── page.module.scss # 同コンポーネントのスタイル
├── show-plain-date-time.tsx # 日付表示コンポーネント(`'use client'` あり)
└── show-plain-date-time.module.scss # 同コンポーネントのスタイル
import * as z from "zod/mini";
export { encode as unwrap, decode as wrap } from "zod/mini";
// encode -> unwrap, decode -> wrap と翻訳する
type TranslateOptions<Opts> = Opts extends {
encode: infer E;
decode: infer D;
}
? { unwrap: E; wrap: D }
: Opts;
type SharableParams<
S extends z.core.SomeType,
U extends z.core.SomeType
> = TranslateOptions<Parameters<typeof z.codec<S, U>>[2]>;
type SharableCodec<
S extends z.core.SomeType,
U extends z.core.SomeType
> = ReturnType<typeof z.codec<S, U>>;
/**
* Shaped("use client" での直列化が不可能なデータ)を、
* Underlying(直列化可能なデータ)に wrap する機能と、
* その逆方向の変換で元のデータに戻す(unwrap)機能を集約した
* Codec スキーマ を提供する。
*/
export function sharable<
S extends z.core.SomeType,
U extends z.core.SomeType = z.core.$ZodType
>(s: S, u: U, params: SharableParams<S, U>): SharableCodec<S, U> {
return z.codec(s, u, { encode: params.unwrap, decode: params.wrap });
}
export type ShapedType<T> = z.input<T>;
export type UnderlyingType<T> = z.output<T>;
import * as z from "zod/mini";
import { Temporal } from "temporal-polyfill";
import { sharable } from "./_utils/share-value-by-codec";
/**
* - 表面的に利用可能な型: PlainDateTime
* - "use client" で直列化するあめの型: string
*/
export const SharablePlainDateTime = sharable(
z.instanceof(Temporal.PlainDateTime),
z.string().brand("SharablePlainDateTime"),
{
wrap: (dateTime) => dateTime.toString(),
unwrap: (string) => Temporal.PlainDateTime.from(string),
}
);
import { Temporal } from "temporal-polyfill";
import { type Metadata } from "next";
import { connection } from "next/server";
import Link from "next/link";
import { ShowPlainDateTime } from "./show-plain-date-time";
import { SharablePlainDateTime } from "./_sharable-plain-date-time";
import { wrap } from "./_utils/share-value-by-codec";
import styles from "./page.module.scss";
export const metadata: Metadata = {
title: "Temporal.PlainDateTime の振る舞いテスト",
};
export default async function Page({}: PageProps<"/rsc-date/temporal">) {
await connection();
const nowPlain = Temporal.Now.plainDateTimeISO();
return (
<div className={styles.container}>
<nav className={styles.nav}>
<Link href="/rsc-date" className={styles.navLink}>
← Back to RSC Date Examples
</Link>
</nav>
<h1>Temporal.PlainDateTime の振る舞いテスト</h1>
<h2>アクセス時のサーバー内部の現在時刻</h2>
<div className={styles.cluster}>
<ShowPlainDateTime
nowPlainDateTime={wrap(SharablePlainDateTime, nowPlain)}
/>
</div>
</div>
);
}
"use client";
import { SharablePlainDateTime } from "./_sharable-plain-date-time";
import {
unwrap,
type ShapedType,
type UnderlyingType,
} from "./_utils/share-value-by-codec";
import styles from "./show-plain-date-time.module.scss";
type Props = {
nowPlainDateTime: UnderlyingType<typeof SharablePlainDateTime>;
};
export function ShowPlainDateTime({
nowPlainDateTime: _nowPlainDateTime,
}: Props) {
const nowPlainDateTime = unwrap(SharablePlainDateTime, _nowPlainDateTime);
return (
<div className={styles.card}>
<span>フォーマット済み: </span>
<span>{formatDateTime(nowPlainDateTime)}</span>
</div>
);
}
const formatDateTime = (d: ShapedType<typeof SharablePlainDateTime>) => {
const year = d.year;
const month = d.month;
const day = d.day;
const hour = d.hour.toString().padStart(2, "0");
const minute = d.minute.toString().padStart(2, "0");
const second = d.second.toString().padStart(2, "0");
return `${year}年${month}月${day}日 ${hour}:${minute}:${second}`;
};
他にもいろいろな Codec があるよ
Zod の Codec としては、他にもいろいろなモノがあります。
先述の通り、'use client'
は Date とかもサポートしているので、Codec が必要になるケースは多くないと思いますが、URL を string にシリアライズする codec なんかは 'use client'
と合わせて使えるかもしれません。
Codec、面白そうなので、必要な場面があれば使ってみたいところですね!
まとめ
'use client'
でのシリアライズ処理においては、Date など、JSON よりも広い範囲のデータがサポートされており、RSC の「サーバー側 / ブラウザ側 のコードをシームレスに繋ぎ合わせる」というコンセプト を体現しています。
Temporal の各型のように、サポートされていないデータ型もありますが、Zod の Codecs 機能 を使えば、わずかなボイラープレートだけで「シリアライズ可能な形に変換 / そこからもとに戻す」処理を追記できるようになり、ある程度の「シームレスさ」をキープできます。
もっと広い視点でみると、Zod や Valibot のようなスキーマライブラリは、単なるフォームやJSONオブジェクトの検証だけでなく、「Opaque Type のように、単一の値をラップする」みたいな使い方まで可能だと考えています。
いわゆる "Parse, don't validate" 原則に従って運用することよって、生の TS の型チェックよりも強力な「静的なチェック」を導入して、実行時エラーの発生しうる範囲を限定することによって、ソースコードを読みやすくする力があると考えています。
Codecs に限らず、スキーマライブラリのポテンシャルが最大限に活用され、世界中のコードがもっと堅牢になることを願って、この記事を締めくくります。
関連記事
Discussion