🚛

Zod Codecs で 'use client' を介した Server-Client 間データ転送を強化する

に公開

'use client' は、サーバーからクライアントへと、描画用データを送信する境界を表すマーカーである ということは、広く知られています。

https://zenn.dev/yumemi_inc/articles/use-client-directive-explained-with-gssp

上記の記事の中では軽く触れるだけにとどめましたが、実は、 'use client' は「素の JSON」よりも多種多様なデータの転送に対応しています。

RSC が「サーバー側 / ブラウザ側 のコードをシームレスに繋ぎ合わせる」技術であることが良くわかりますね。

サーバコンポーネントからクライアントコンポーネントに渡される props の値は、シリアライズ可能 (serializable) である必要があります。

シリアライズ可能な props には以下のものがあります:

出典:

'use client' だけで Date を送れる

先ほど述べた通り、 'use client' でマークされた Client Component に、Server Component から Date オブジェクトを渡すことは可能です。

以下の例では、 Date をサーバーからクライアントに送る口実として、「サーバー側で現在時刻を取得して、ブラウザ側でそれを端末のタイムゾーンに基づいて表示する」という、Client Component 抜きでは実装しづらい機能を実装しています。

ISO: 2025-09-25T08:15:34.840Z ブラウザ側タイムゾーンでの時刻: Thursday, September 25, 2025 at 1:15:34 AM PDT
実際の画面の様子: 世界協定時の時刻と、PDT(ブラウザ側のタイムゾーンであるサンフランシスコの現地時刻)が表示されている

▼コンポーネント間でデータが受け渡される様子の概略図

(抜粋) src/app/rsc-date/date/page.tsx
// 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>
  );
};
(抜粋) src/app/rsc-date/date/locale-aware-date.tsx
"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>
  );
}
ソースコード全文
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) {
  // 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' ディレクティブを設定しています。)

Only plain objects can be passed to Client Components from Server Components. Temporal.PlainDateTime objects are not supported.

しかし、これで終わりではありません。

シリアライズ可能なデータに変換して送信する

  • Server Component 側では、《'use client' でシリアライズ可能なデータ型》に変換して、Props に渡す。
    • エンコード(encode)
  • Client Component 側では、それを受け取って、元の型に復元する
    • デコード(decode)

という少しの手間を掛けさえすれば、とりあえず対応できます。

とりあえずこれで目的は果たせますが、しかし、実装するとなると、なんかゴチャッとして使いにくいユーティリティーになる匂いがします。

JavaScript/TypeScript は名前空間機能がなく、関数の名前は長〜〜い動詞句になってしまい、いろいろな情報を提供しようとすると、それぞれバラバラな名前になってしまうので、こんな感じになってしまいます。

// Server Component 側: PlainDateTime -> string
import { encodeFromPlainDateTime } from "#/utils/react/codecs/plain-date-time";
// Client Component 側: string -> PlainDateTime
import { decodeIntoPlainDateTime } from "#/utils/react/codecs/plain-date-time";

// 型の情報を SSoT (信頼できる唯一の情報源)として提供したい
import {
  type PlainDateTime,
  type PlainDateTimeUnderlying, // つまり string
} from "#/utils/react/codecs/plain-date-time";

VSCode の画面。plainDateTime と入力したところ、ShowPlainDateTime, SharablePlainDatTime, decodeToPlainDateTime, encodeFromPlainDateTime の 4つをimport 補完の候補として見せてくれている。
import 自動補完が全く役に立たない訳ではないが…

「動詞が最初に来て、その後に目的語が来る」このパターンは、UI デザインの文脈では「タスク指向 UI」、つまりユーザーに「作る側の都合」を押し付ける、悪い UI の設計とされるでしょう。

(対義語は「オブジェクト指向 UI」(OOUI)で、作り手の都合を廃して、ユーザーの脳内のイメージに合わせて作る、理想的な設計を指しています。)

ユーティリティ関数のたぐいも、実装者である同僚 etc. をユーザーと考えると一種の UI なので、「よい UI」を目指せるなら目指したいですよね?

SSoT オブジェクトに全てを集約する

では OOUI 的にするために、1つのオブジェクトに情報を集約して、その SSoT(信頼できる唯一の情報源)から情報を引き出すためのユーティリティと組み合わせて利用できるようにしてみましょう。

(ツリーシェイキングは犠牲になりますが、それほど大きなコード量にはならないので妥協することにします。)

utils/react/codecs/_codec
export type Codec<in out S, in out U> = {
  decode: (u: U) => S;
  encode: (s: S) => U;
};

export type ShapedType<C> = C extends Codec<infer S, infer _> ? S : never;
export type UnderlyingType<C> = C extends Codec<infer _, infer U>
  ? U
  : never;
utils/react/sharable/plain-date-time
import { Temporal } from "temporal-polyfill";
import type { Codec } from "./_codec";

export const SharablePlainDateTime: Codec<Temporal.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";

// Server Component 側: PlainDateTime -> string
SharablePlainDateTime.encode(plainDateTime);
// Client Component 側: string -> PlainDateTime
SharablePlainDateTime.decode(underlying);

// sharable ユーティリティを使って、SSoT としての Codec オブジェクトから型情報を抽出できる
type Props = {
  now: UnderlyingType<typeof SharablePlainDateTime>,
};
type PlainDateTime = ShapedType<typeof SharablePlainDateTime>;

こうすれば、

  • エンコード関数
  • デコード関数
  • Shaped 型の情報
  • Underlying 型の情報

が全て、SSoT たる PlainDateTimeCodec オブジェクトから派生的に得られるようになっています。これはかなり OOUI 的でわかりやすいと思います。

"#/utils/react/sharable" ユーティリティの使い方をユーザーが知っておく必要があるのは不便に思われるかもしれませんが、情報がうまく集約されたことと比べれば、必要な学習コストだと割り切っても良いでしょう。

Zod Codecs のパターンに乗っかる

上記では、お手製の Codec ユーティリティを使って、シリアライゼーションのための双方向の型変換にパターンを規定してあげて、取り扱いを容易にしました。

しかし、実は、このパターンを提供してくれるようになったライブラリがあります。

それが Zod です。

Zod は v4.1 から、Codecs という双方向の型変換をサポートする仕組みを提供しています。(従来のスキーマは、単一方向の型変換のみサポートしていました。)

https://zod.dev/codecs

この Codec の仕組みは、そのまま今回のユーティリティに採用できます。

アクセス時のサーバー内部の現在時刻 フォーマット済み: 2025年9月25日 17:15:43
完成品。ブラウザ側のタイムゾーン(「サンフランシスコ」に設定されている)に左右されず、アクセス時のサーバー内部(JST)の現在時刻を表示できている。

ISO: 2025-09-25T08:15:34.840Z ブラウザ側タイムゾーンでの時刻: Thursday, September 25, 2025 at 1:15:34 AM PDT
比較用: 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";

/**
 * - encode: Shaped -> Underlying
 * - decode: Underlying -> Shaped
 *
 * - Shaped: サーバー <-> クライアント 間で共有したいが、直列化が不可能なデータ型
 * - Underlying: "use client" で直列化可能なデータ型
 */
export { codec } from "zod/mini";
export { encode, decode } from "zod/mini";

export type UnderlyingType<T> = z.input<T>;
export type ShapedType<T> = z.output<T>;
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";

/**
 * - 表面的に利用可能な型: PlainDateTime
 * - "use client" で直列化するあめの型: string
 */
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: UnderlyingType<typeof 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: 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 としては、他にもいろいろなモノがあります。

https://zod.dev/codecs?id=stringtourl#useful-codecs

先述の通り、'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 に限らず、スキーマライブラリのポテンシャルが最大限に活用され、世界中のコードがもっと堅牢になることを願って、この記事を締めくくります。

関連記事

https://zenn.dev/terrierscript/articles/2023-06-05-temporal

https://zenn.dev/terrierscript/books/2023-01-typed-zod

株式会社ゆめみ

Discussion