White Box技術部

WEB開発のあれこれ(と何か)

【React】react-intlでZodの多言語対応をうまくやる方法

Zodのスキーマ定義は別ファイルで管理したい!

Zodのスキーマ定義はクライアント側のチェックでもサーバー側のチェックでも利用したいので、コンポーネント内で定義するのではなく別ファイルで管理したいマンなのですが、react-intlが採用されたプロジェクトでそうしようとしたとき、一筋縄では行かなかったので、解決策を残しておきます。

ちなみに関連パッケージのバージョンは以下のとおりです。

"@hookform/resolvers": "5.2.1",
"next": "16.0.7",
"react": "19.2.1",
"react-intl": "7.1.11",
"zod": "3.25.58",

問題点

スキーマ定義は多言語対応しない場合は以下のように定義することができます。

export const editUserSchema = z.object({
  name: z
    .string()
    .min(1, "必須項目です")
    .max(100, "名前は100文字以下で入力してください"),
});

これを以下のようにメッセージ定義して、

const message = defineMessage({
  required: '必須項目です',
  maxLength: '名前は{length}文字以下で入力してください',
});

さあintl.formatMessage(message.required)と書こうとすると、

intlはどうするんだ?

となってしまいます。

クライアント側であればuseIntl()フックを使って取得できますし、サーバー側はcreateIntl関数で生成することでintlを取得できますが、スキーマ定義を別ファイルにするのであればどちらかに依存しては意味がありません。

よく行われる解決策

zodの返却するエラーメッセージはただのstringなので、メッセージにはMessageDescriptorのidを設定し、エラーメッセージの表示側でそれぞれメッセージ文言に変換する方法が現状よく利用されているようです。

具体的にはschemas.tsには以下のように定義し、

const message = defineMessage({
  required: '必須項目です',
});
export const editUserSchema = z.object({
  name: z
    .string()
    .min(1, message.required.id),
});

例えばクライアント側では、以下のように利用するという感じです。

const intl = useIntl();
const {
    register,
    formState: { errors },
} = useForm<z.infer<typeof editUserSchema>>({
  resolver: zodResolver(editUserSchema),
  defaultValues: { name: '' },
});

const errorMessage = intl.formatMessage({ id: errors.name?.message });

ちなみにzodの話だけであれば、zod-i18nを使うと簡単かもしれません

本格的な解決

上記の方法では、パラメータをメッセージに渡すことができません。
現状Zodの返却値はstringだけなので、後は力技で解決することにしました。

具体的には、

  1. スキーマ定義内のメッセージには必要情報が入ったJSON文字列を定義する
  2. 利用側ではJSON文字列をパースしてformatMessageに設定する

です。

まずは、ユーティリティとして以下の2つの関数を定義します。
getJsonErrorMessageがJSON文字列作成用、getSchemaCheckMessageが実際に出したいメッセージ出力用です。

import { z } from 'zod';
import { IntlShape } from 'react-intl';

export const getJsonErrorMessage = (
  messageId: string | undefined,
  params?: {}
): string => {
  return JSON.stringify({ id: messageId, ...params });
};

export const getSchemaCheckMessage = (
  intl: IntlShape,
  value: string | undefined
): string => {
  if (!value) {
    return '';
  }
  try {
    const message = JSON.parse(value);
    if (!message?.id) {
      return message;
    } else if (intl.messages[message.id] === '') {
      return value;
    } else {
      const { id, ...rest } = message;
      return intl.formatMessage({ id: message.id }, rest);
    }
  } catch (e) {
    if (e instanceof SyntaxError) {
      return value;
    }
    throw e;
  }
};

スキーマ定義では以下のように記述します。

const message = defineMessage({
  required: '必須項目です',
  maxLength: '名前は{length}文字以下で入力してください',
});

export const editUserSchema = z.object({
  name: z
    .string()
    .min(1, getJsonErrorMessage(message.required.id))
    .max(100, getJsonErrorMessage(message.maxLength.id, {
       length: 100,
      })
    ),
});

そして使う側は以下のようになります。

const intl = useIntl();
const {
    register,
    formState: { errors },
} = useForm<z.infer<typeof editUserSchema>>({
  resolver: zodResolver(editUserSchema),
  defaultValues: { name: '' },
});

const errorMessage = getSchemaCheckMessage(intl, errors.name?.message);

サーバーサイドでは以下のような感じでしょうか。こっちはあくまで無理やり使った雰囲気コードです。

"use server";
import z from "zod";
import { createIntl, createIntlCache } from 'react-intl';

const cache = createIntlCache();

export const editUser = async (props: z.infer<typeof editUserSchema>) => {
  const locale = await getServerLocale();
  const intl = createIntl({ locale, messages: i18nMessages, defaultLocale: 'ja-JP' }, cache);
  const parsed = editUserSchema.safeParse(props);
  if (!parsed.success) {
    const nameErrors = z.flattenError(parsed.error).fieldErrors.name;
    if (nameErrors?.length) {
      for (const err of nameErrors) {
        console.error("Name error:", getSchemaCheckMessage(intl, err));
      }
    }
    ...
  }
...
};

これで問題なくreact-intlも使え、スキーマ定義を別ファイルにすることもできるようになりました。

多言語対応は大変すぎますね。日本語に対応してくれているサイトには感謝しかないです。

関連リンク

React TableにReactの警告が出る話(incompatible-library)

新しいプロジェクトでReact Tableを使おうとしたところ、以下のような警告が発生しました。

This API returns functions which cannot be memoized without leading to stale UI.
To prevent this, by default React Compiler will skip memoizing this component/hook.
However, you may see issues if values from this API are passed to other components/hooks that are memoized.

|   const table = useReactTable({
|                 ^^^^^^^^^^^^^ TanStack Table's `useReactTable()` API returns functions that cannot be memoized safely

前、使ったときには出ていなかったので、原因と対処法を調べたログを残しておきます。

実装環境

本警告が発生したプロジェクトで利用している、関連していそうなライブラリのバージョンは以下の通りです。

"@tanstack/react-table": "8.21.3",
"react": "19.2.4",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-config-next": "16.1.6",

問題なかったプロジェクトでのライブラリバージョンは以下の通りです。

"@tanstack/react-table": "8.21.3",
"react": "19.1.1",
"eslint-plugin-react": "7.37.5",
"eslint-config-next": "15.5.9",

eslint-config-next@npm:15.5.9に関連して、
eslint-plugin-react-hooks@npm:^5.0.0(実際は5.2.0)

原因

eslint-plugin-react-hooksのバージョンが上がったため

このメッセージ自体はReact 19における新しいESLintルールの一つに該当しているもののようでした。

incompatible-library - Validates against usage of libraries which are incompatible with memoization

ドキュメントからはこのエラーがいつから出るようになったのか読み取れなかったので、コードの方を調べてみたところ、 以下のコミットログで2025/8/29に実装されているようです。

[compiler] Detect known incompatible libraries (#34027) · facebook/react@4082b0e · GitHub

そして2025/10/2に公開されたReact 19.2.0には、[email protected]が含まれているので、ここらへんから警告が出るようになってたのではないかと思われます。

Release 19.2.0 (Oct 1, 2025) · facebook/react · GitHub

ちなみに5.0.0は2024/10/11が公開日でした。
Release [email protected] (Oct 11, 2024) · facebook/react · GitHub

対応方法

Issueを読むに現状ではReact Table側の対応待ちのようです。 雰囲気的にv9で対応されていたら早いくらいの感じなので、しばらくは様子見になるかと思います。

なので現状の対応方法としては、Issue内容に従い、useReactTableを利用している関数の先頭に"use no memo";を記載しつつ、 これだけではエディタ上の警告が消えないので、以下のようにESLintを無効にするくらいしかなさそうです。

"use no memo";

-- 中略 --

// MEMO: https://github.com/TanStack/table/issues/5567
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({

警告なので出しっぱなしでも良さそうですが、私はメモを残しておくスタイルが好きですかね(忘れるので・・・

ファイルをドラッグアンドドロップして、ファイル名の先頭に日付を付与するバッチ

何故か実装されない機能

ペイントツールであるクリスタを使っていると、ファイル名に日付をつけて保存したくなるのですが、ファイル名の初期値を「イラスト.clip」から日付を含む文字列に変更することはできず、一旦ペンを置いてキーボード入力することになるのが億劫で困っていました。
キーボードも普段エディタを使っているときのように中心に置くこともできないので、入力するポジションも普段と違ううえ、日付はキーボード的にキーが散らばっているので、妙に面倒くさいわけです。

セリに電流走る⚡️

「保存はデフォルト名にして、バッチファイル使って日付文字列を付与したらいいのでは?」

ふとバッチファイルにファイルをドラッグアンドドロップすることで特定の処理が行えることを思い出し、そんな処理を作ることにしました。

バッチファイルの作成

クリスタはWindows11で使っていたので、以下のようなWindows用のバッチファイルを作成し、クリスタのファイルを保存する場所に置きました。

@echo off
setlocal enabledelayedexpansion

rem ----------------------------------------
rem 現在の日付を取得し、ファイル名に追加するバッチファイル
rem 使用方法: add_date_prefix.batにファイルをドロップ
rem   例:sample.txt を 20250714_sample.txt に変更
rem ----------------------------------------
set TODAY=%date:~0,4%%date:~5,2%%date:~8,2%
set FNAME=%~n1
set EXT=%~x1
ren %1 "!TODAY!_!FNAME!!EXT!"

あとは一度保存したら、フォルダからクリスタのファイルをこのバッチファイルにドラッグアンドドロップすればOKです。

Macの場合

Macの場合はAutomatorを使って同じ事ができます。手順は、

  1. AutomatorからRun Shell Scriptを選び、
  2. Pass inputをas argumentsに変更し、
  3. 以下のコードを貼り付けて保存する

という流れになります。

for f in "$@"
do
    # 今日の日付を取得(YYYYMMDD形式)
    TODAY=$(date +%Y%m%d)
    
    # ファイルパスから情報を取得
    DIRNAME=$(dirname "$f")
    BASENAME=$(basename "$f")
    FNAME="${BASENAME%.*}"
    EXT="${BASENAME##*.}"
    
    # 拡張子がない場合の処理
    if [ "$FNAME" = "$EXT" ]; then
        NEWNAME="${TODAY}_${BASENAME}"
    else
        NEWNAME="${TODAY}_${FNAME}.${EXT}"
    fi
    
    # リネーム実行
    mv "$f" "${DIRNAME}/${NEWNAME}"
done

GUIの操作はペンでもやりやすいので、多少手間が増えた割にはストレス無く名前を変えることができるようになり、満足していました。

「ん?Google日本語入力で日付を辞書登録できるのでは?」

YYYYMMDD形式が入力し辛いのであって、それがすぐ出るなら別に大丈夫な気にシャワーを浴びているときになりました。

調べてみたところYYYYMMDD形式を「きょう」と入力することで出す方法はあるようです。

具体的には以下の辞書を追加するだけです。

  • Reading: DATE_FORMAT
  • Word: {YEAR}{MONTH}{DATE}
  • Category: 名詞

これで「きょう」を入力したときに新しくYYYYMMDD形式でも変換できるようになりました。

「・・・違うんだよ」

まあ確かにこれでできるのですが、本当は「d」一文字とかで出したかったわけです。 ちょっと試してみましたが、それはどうもうまくいかないので諦めました。

落ち穂拾い

今回Windowsのバッチファイルのコードを貼り付けるに当たって、シンタックスハイライトのための文字列がわからなくて調べたのですが、以下のページに書いてありました。

ソースコードを色付けして表示する(シンタックスハイライト) - はてなブログ ヘルプ

ちなみにバッチファイルはwinbatchでした。

【React】React Hook FormのdirtyFieldsは(初期値があると)ちゃんと機能する

「もうdirtyFields、ちゃんと機能するようになってると思うよ」

そう言われたのでちょっと調べてみました。

経緯

私がReact Hook Formを使い始めたときは、確かdirtyFieldsがフォーム値を変更前の値に戻しても変更がなかったと判断してくれなかったので、以下のような関数を1つ作って間に噛ませて使っていました。

import { FieldValues, UseFormWatch } from 'react-hook-form';

export const checkDirty = <T>(before: T, after: T) => {
  if (before) {
    return before != after;
  } else {
    return after ? true : false;
  }
};

// 第1引数は、新規作成ではnullを、編集ではformの初期値を設定する
// 利用例)
// const isDirty = useDirtyForm<T>(null, dirtyFields, watch);
// isDirty('url');
export const useDirtyForm = <T extends FieldValues>(
  form: T | null,
  dirtyFields: Partial<
    Readonly<{
      [P in keyof T]: boolean | boolean[] | object | undefined;
    }>
  >,
  watch: UseFormWatch<T>
): ((name: keyof T) => boolean) => {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  return (name: keyof T) => (dirtyFields[name] ? checkDirty(form ? form[name] : null, watch(name as any)) : false);
};

あれから3,4年くらい経ち、React Hook Formのバージョンも以下のように変化したので、まあ確かにもう大丈夫かもと思い、検証してみました。

  • 昔:7.22.3あたり
  • 今:7.71.0

検証

動作確認用にリポジトリを作成しました。

ここでは文字列項目で以下のパターンを検証します。

  • 初期値なしでのdirtyFieldsの挙動
  • 初期値あり(空文字)でのdirtyFieldsの挙動
  • 初期値あり(文字あり)でのdirtyFieldsの挙動

メインのコードは以下です。

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const formSchema = z.object({
  notInitialized: z.string(),
  initializedEmpty: z.string(),
  initializedVaild: z.string(),
});

type FormInputs = z.infer<typeof formSchema>;

export function TestForm() {
  const {
    register,
    formState: { dirtyFields },
  } = useForm<FormInputs>({
    mode: 'onBlur',
    defaultValues: {
      initializedEmpty: '',
      initializedVaild: 'sample',
    },
    resolver: zodResolver(formSchema),
  });

  const getBorderColor = (field: keyof FormInputs) => {
    return dirtyFields[field] ? '2px solid #2cac4c' : '1px solid';
  };

  return (
    <form style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
      <p>各項目はonBlurでdirtyFieldsがtrueになる場合、緑枠が付きます。</p>
      <div>
        <label htmlFor="notInitialized">初期値なし</label>
        <input
          id="notInitialized"
          type="text"
          {...register('notInitialized')}
          style={{
            width: 'calc(100% - 1rem)',
            display: 'block',
            padding: '0.5rem',
            marginTop: '0.25rem',
            border: getBorderColor('notInitialized'),
          }}
        />
      </div>

      <div>
        <label htmlFor="initializedEmpty">初期値あり(空文字)</label>
        <input
          id="initializedEmpty"
          type="text"
          {...register('initializedEmpty')}
          style={{
            width: 'calc(100% - 1rem)',
            display: 'block',
            padding: '0.5rem',
            marginTop: '0.25rem',
            border: getBorderColor('initializedEmpty'),
          }}
        />
      </div>

      <div style={{ width: '100%' }}>
        <label htmlFor="initializedVaild">初期値あり(文字あり)</label>
        <input
          id="initializedVaild"
          type="text"
          {...register('initializedVaild')}
          style={{
            width: 'calc(100% - 1rem)',
            display: 'block',
            padding: '0.5rem',
            marginTop: '0.25rem',
            border: getBorderColor('initializedVaild'),
          }}
        />
      </div>
    </form>
  );
}

これが表示される画面の初期状態はこんな感じです。

結果

項目に値を入力するとどれもdirtyFieldsが発火します。

そして入力した値を削除した場合はこちらです。

ご覧のように、初期値があれば正しく動作するようです。

結論(と振り返り)

「初期値が設定されていれば、dirtyFieldsは意図通り動作するが、初期値が設定されていないと変更ありと判断される」

ということのようです。

では何故過去の私は動作しないと判断したのか。ええ、当時は若く、初期値が空の項目にdefaultValuesを使っていなかったからでしょうね・・・

つまりReact Hook Formを使う際、defaultValuesをちゃんと使っていれば、問題なくdirtyFieldsは動作します。
初期値なしでもDirty判定させたい場合は、最初に提示した関数を噛ますと、期待した動作をします。

ただ、以前どこかでも書いた気がするのですが、React Hook FormはdefaultValuesを使うのが基本のようなので、defaultValuesを設定して使うのが良いかと思います。

ちなみに、一度入力項目で入力したら、元の値に戻しても操作があったことを知りたい場合は、touchedFieldsを使えばOKです。