金曜日, 9月 26, 2025
金曜日, 9月 26, 2025
- Advertisment -
ホームニューステックニュースZod Codecs で 'use client' を介した Server-Client 間データ転送を強化する

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 { 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' ディレクティブを設定しています。)

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 側では、それを受け取って、元の型に復元する

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

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

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";

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 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 という双方向の型変換をサポートする仕組みを提供しています。(従来のスキーマは、単一方向の型変換のみサポートしていました。)

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";


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 としては、他にもいろいろなモノがあります。

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



Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -