👊

react-i18nextに依存しない形で、react-hook-formとzodで多言語対応バリデーションメッセージを表示させる

2024/07/10に公開

概要

以下の例のように、いくつかの画面(例えばメンバー作成画面とメンバー編集画面)から呼び出されるuseMemberがあります。
その際にフォームで使用しているschemaをexportしたい要件があります。

export const schema = z.object({
    name: z
      .string()
      .min(MemberName.min, {
        message: "必須のバリデーション"
      })
      .max(MemberName.max, {
        message: "最大値のバリデーションメッセージ",
      })
  })

export type MemberFormValues = z.infer<typeof schema>


export const useMember = () => {
  const form = useForm<MemberFormValues>({
    resolver: zodResolver(schema)
  })
  return {
    form
  }
}

しかし、i18nで多言語対応させようとするとuseTranslationを使用するのでhooksの中に書かなければなりません。
そうすると以下のようにschemaのtypeをexportすることができなくなります。

import { useTranslation } from 'react-i18next'
// exportできない
// export type MemberFormValues = z.infer<typeof schema>
export const useMember = () => {
  const { t } = useTranslation()

  // この型をexportしたい
  const schema = z.object({
    name: z
      .string()
      .min(MemberName.min, {
        message: t("memberNameRequired")
      })
      .max(MemberName.max, {
        message: t("memberNameMax")
      })
  })

  const form = useForm<z.infer<typeof schema>>({
    resolver: zodResolver(schema)
  })
  return {
    form
  }
}

なので、こちらの記事のようにi18nextをフル活用して、メッセージやバリデーションの情報をアプリケーションの最初にすべて定義して一箇所で中央集権的に管理する方法もありましたが、アプリケーションすべてのバリデーションのメッセージを一箇所で管理するのは個人的には好きではいし、依存度が高かったため別の方法を取りました。

https://zenn.dev/aiji42/articles/171f26af4e5b5c

実装

react-i18nextに依存しないために

i18nにはreact-i18nextを使用していますが、react-i18nextに依存したくないので、i18nProviderという多言語情報を提供するProviderを作成してreact-i18nextを隠蔽しています。
それを最初にContextにセットして呼び出せるようにしています。

import { useTranslation } from "react-i18next"

export interface I18nProvider {
  translate(key: string, variables?: { [k: string]: any } | undefined): string
}

export class ReactI18nextProvider implements I18nProvider {
  translate(key: string, variables?: { [k: string]: any } | undefined): string {
    const { t } = useTranslation()
    return t(key, variables)
  }
}

具体的なコード

例ではuseMemberというメンバー作成フォームを例にしています。
いくつかの画面(例えばメンバー作成画面とメンバー編集画面)から呼び出された場合にuseMemberを呼び出します。

基本的には記事の上の方にあったコードと同じですが、変更点としては以下になります。

  1. memberFormSchemaを作る際にi18nProviderを渡すようにした
  2. MemberFormValuesで戻りの型にReturnTypeを使用
import { zodResolver } from "@hookform/resolvers/zod"
import { useContext } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { MemberId } from "~/domain/member/id"
import { MemberName } from "~/domain/member/name"
import { i18nKeys } from "~/infrastructure/i18n/i18n"
import type { ReactI18nextProvider } from "~/infrastructure/i18n/provider"
import { ContainerContext } from "~/infrastructure/injector/context"

export const memberFormSchema = (i18nProvider: I18nProvider) =>
  z.object({
    name: z
      .string()
      .min(MemberName.min, {
        message: i18nProvider.translate(i18nKeys.form.required, {
          field: i18nProvider.translate(i18nKeys.word.memberName)
        })
      })
      .max(MemberName.max, {
        message: i18nProvider.translate(i18nKeys.form.max, {
          field: i18nProvider.translate(i18nKeys.word.memberName),
          max: MemberName.max
        })
      })
  })

export type MemberFormValues = z.infer<ReturnType<typeof memberFormSchema>>

export const useMember = () => {
  const { i18n } = useContext(ContainerContext)

  const schema = memberFormSchema(i18n)
  const form = useForm<z.infer<typeof schema>>({
    resolver: zodResolver(schema)
  })

  return {
    form
  }
}

戻りの方をそのままtypeof memberFormSchemaにしてしまうとFunctionの型が返ってしまいますが、ReturnTypeを使うことで結果の戻り値を型として返してくれます。

このようにすることで具体的な多言語ライブラリに依存せずにschemaをexportすることができます。

このアプローチは、特に大規模なプロジェクトや、将来的な拡張性を重視する場合に適していると思います。
小規模なプロジェクトではこのような抽象化が過剰になる可能性もあるため、プロジェクトの要件に応じて適切に判断してください👌

Discussion