'use client'
は、サーバーからクライアントへと、描画用データを送信する境界を表すマーカーである ということは、広く知られています。
上記の記事の中では軽く触れるだけにとどめましたが、実は、 'use client'
は「素の JSON」よりも多種多様なデータの転送に対応しています。
RSC が「サーバー側 / ブラウザ側 のコードをシームレスに繋ぎ合わせる」技術であることが良くわかりますね。
サーバコンポーネントからクライアントコンポーネントに渡される props の値は、シリアライズ可能 (serializable) である必要があります。
シリアライズ可能な props には以下のものがあります:
出典:
'use client'
だけで Date を送れる
先ほど述べた通り、 'use client'
でマークされた Client Component に、Server Component から Date オブジェクトを渡すことは可能です。
以下の例では、 Date
をサーバーからクライアントに送る口実として、「サーバー側で現在時刻を取得して、ブラウザ側でそれを端末のタイムゾーンに基づいて表示する」という、Client Component 抜きでは実装しづらい機能を実装しています。
実際の画面の様子: 世界協定時の時刻と、PDT(ブラウザ側のタイムゾーンであるサンフランシスコの現地時刻)が表示されている
▼コンポーネント間でデータが受け渡される様子の概略図
(抜粋) src/app/rsc-date/date/page.tsx
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>
);
};
(抜粋) src/app/rsc-date/date/locale-aware-date.tsx
"use client";
const formatter = new Intl.DateTimeFormat(undefined, {
});
type Props = {
value: Date;
};
export function LocaleAwareDate({ value }: Props) {
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>
);
}
ソースコード全文
src/app/rsc-date/date/page.tsx
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>
);
}
src/app/rsc-date/date/locale-aware-date.tsx
"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) {
const localDate = useSyncExternalStore(
() => () => {},
() => formatter.format(value),
() => undefined
);
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 に渡す。- エンコード(encode)
- Client Component 側では、それを受け取って、元の型に復元する
という少しの手間を掛けさえすれば、とりあえず対応できます。
とりあえずこれで目的は果たせますが、しかし、実装するとなると、なんかゴチャッとして使いにくいユーティリティーになる匂いがします。
JavaScript/TypeScript は名前空間機能がなく、関数の名前は長〜〜い動詞句になってしまい、いろいろな情報を提供しようとすると、それぞれバラバラな名前になってしまうので、こんな感じになってしまいます。
import { encodeFromPlainDateTime } from "#/utils/react/codecs/plain-date-time";
import { decodeIntoPlainDateTime } from "#/utils/react/codecs/plain-date-time";
import {
type PlainDateTime,
type PlainDateTimeUnderlying,
} from "#/utils/react/codecs/plain-date-time";
import 自動補完が全く役に立たない訳ではないが…
「動詞が最初に来て、その後に目的語が来る」このパターンは、UI デザインの文脈では「タスク指向 UI」、つまりユーザーに「作る側の都合」を押し付ける、悪い UI の設計とされるでしょう。
(対義語は「オブジェクト指向 UI」(OOUI)で、作り手の都合を廃して、ユーザーの脳内のイメージに合わせて作る、理想的な設計を指しています。)
ユーティリティ関数のたぐいも、実装者である同僚 etc. をユーザーと考えると一種の UI なので、「よい UI」を目指せるなら目指したいですよね?
SSoT オブジェクトに全てを集約する
では OOUI 的にするために、1つのオブジェクトに情報を集約して、その SSoT(信頼できる唯一の情報源)から情報を引き出すためのユーティリティと組み合わせて利用できるようにしてみましょう。
(ツリーシェイキングは犠牲になりますが、それほど大きなコード量にはならないので妥協することにします。)
utils/react/codecs/_codec
export type Codecin out S, in out U> = {
decode: (u: U) => S;
encode: (s: S) => U;
};
export type ShapedTypeC> = C extends Codecinfer S, infer _> ? S : never;
export type UnderlyingTypeC> = C extends Codecinfer _, infer U>
? U
: never;
utils/react/sharable/plain-date-time
import { Temporal } from "temporal-polyfill";
import type { Codec } from "./_codec";
export const SharablePlainDateTime: CodecTemporal.PlainDateTime, string> = {
decode: (string) => Temporal.PlainDateTime.from(string),
encode: (dateTime) => dateTime.toString(),
}
import { type ShapedType, type UnderlyingType } "#/utils/react/sharable";
import { SharablePlainDateTime } from "#/utils/react/sharable/plain-date-time";
SharablePlainDateTime.encode(plainDateTime);
SharablePlainDateTime.decode(underlying);
type Props = {
now: UnderlyingTypetypeof SharablePlainDateTime>,
};
type PlainDateTime = ShapedTypetypeof SharablePlainDateTime>;
こうすれば、
- エンコード関数
- デコード関数
- Shaped 型の情報
- Underlying 型の情報
が全て、SSoT たる PlainDateTimeCodec
オブジェクトから派生的に得られるようになっています。これはかなり OOUI 的でわかりやすいと思います。
"#/utils/react/sharable"
ユーティリティの使い方をユーザーが知っておく必要があるのは不便に思われるかもしれませんが、情報がうまく集約されたことと比べれば、必要な学習コストだと割り切っても良いでしょう。
Zod Codecs のパターンに乗っかる
上記では、お手製の Codec
ユーティリティを使って、シリアライゼーションのための双方向の型変換にパターンを規定してあげて、取り扱いを容易にしました。
しかし、実は、このパターンを提供してくれるようになったライブラリがあります。
それが Zod です。
Zod は v4.1 から、Codecs という双方向の型変換をサポートする仕組みを提供しています。(従来のスキーマは、単一方向の型変換のみサポートしていました。)
この Codec の仕組みは、そのまま今回のユーティリティに採用できます。
完成品。ブラウザ側のタイムゾーン(「サンフランシスコ」に設定されている)に左右されず、アクセス時のサーバー内部(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 # 同コンポーネントのスタイル
src/app/rsc-date/temporal/_utils/share-value-by-codec.ts
import * as z from "zod/mini";
export { codec } from "zod/mini";
export { encode, decode } from "zod/mini";
export type UnderlyingTypeT> = z.inputT>;
export type ShapedTypeT> = z.outputT>;
src/app/rsc-date/temporal/_sharable-plain-date-time.ts
import { z } from "zod/mini";
import { Temporal } from "temporal-polyfill";
import { codec } from "./_utils/share-value-by-codec";
export const SharablePlainDateTime = codec(
z.string(),
z.instanceof(Temporal.PlainDateTime),
{
decode: (string) => Temporal.PlainDateTime.from(string),
encode: (dateTime) => dateTime.toString(),
}
);
src/app/rsc-date/temporal/page.tsx
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 { encode } 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={encode(SharablePlainDateTime, nowPlain)}
/>
div>
div>
);
}
src/app/rsc-date/temporal/show-plain-date-time.tsx
"use client";
import { SharablePlainDateTime } from "./_sharable-plain-date-time";
import {
decode,
type ShapedType,
type UnderlyingType,
} from "./_utils/share-value-by-codec";
import styles from "./show-plain-date-time.module.scss";
type Props = {
nowPlainDateTime: UnderlyingTypetypeof SharablePlainDateTime>;
};
export function ShowPlainDateTime({
nowPlainDateTime: _nowPlainDateTime,
}: Props) {
const nowPlainDateTime = decode(SharablePlainDateTime, _nowPlainDateTime);
return (
div className={styles.card}>
span>フォーマット済み: span>
span>{formatDateTime(nowPlainDateTime)}span>
div>
);
}
const formatDateTime = (d: ShapedTypetypeof 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 に限らず、スキーマライブラリのポテンシャルが最大限に活用され、世界中のコードがもっと堅牢になることを願って、この記事を締めくくります。
関連記事
Views: 0