バイセル Tech Blog

バイセル Tech Blogは株式会社BuySell Technologiesのエンジニア達が知見・発見を共有する技術ブログです。

バイセル Tech Blog

実務で使った React-Hook-Form × Zod の事例紹介

はじめに

こちらはバイセルテクノロジーズ Advent Calendar 2022の 24 日目の記事です。

前日の記事は田中さんの「環境構築をコマンドでまとめてみた」の記事でした。

​ こんにちは! テクノロジー戦略本部 開発二部の小林です。

​ 自分が担当したプロジェクトでは、弊社で初めてバリデーションライブラリとして Zod を使用し、React-Hook-Form × Zod の構成でフォームを作成しました。

​ 本記事では、実際にプロジェクトで実装した事例を紹介したいと思います。

​React-Hook-Form × バリデーションライブラリの技術選定に迷っている方がいましたら、ご参考になれば幸いです。

対象読者

本記事は、React-Hook-Form を使ったことはあるが、バリデーションライブラリに悩んでいる方を対象としています。
そのため、React-Hook-Form・Zod の基本的な使い方については説明を省略しています。
0 から学びたい方は、公式・別記事をご一読の上、本記事を読み進めていただければ幸いです。

React-Hook-Form とは

https://react-hook-form.com/

​ 「性能、柔軟性、拡張性に優れたフォームと、使いやすいバリデーション」を掲げている、React 向けのフォーム管理ライブラリです。 ​

Zod とは

https://github.com/colinhacks/zod

​ Zod とは、TypeScript First なバリデーションライブラリです。

React 版 Ruby on Rails とも言える、フルスタックフレームワークのBlitz でも使用されているようです。 ​

なぜ React-Hook-Form とバリデーションライブラリを組み合わせるのか

React-Hook-Form には、カスタムロジックを組むために、validate API が用意されており、自由にバリデーションロジックを作成することができます。

​ しかし、他のフォームの値を参照するような複雑なバリデーションを実現しようとすると、どうしてもコンポーネント側にバリデーションロジックを書かないといけなくなり、ロジックが散在してしまうことで、以下のような問題が発生していました。 ​

バリデーションライブラリ未使用時の問題点

  • 管理がしづらい
  • テストが書きづらい
  • バリデーションロジックが使い回しづらい

具体例

以下は、本記事内で紹介する事例 2をバリデーションライブラリを使用しないで書き直した例です。
status を price と reason のバリデーション時に参照したいため、React-Hook-Form の API を使用しなくてはならず、App コンポーネント内にバリデーションロジックを書かないといけなくなっています。

以下コードのデモ環境はこちら

import { useForm } from 'react-hook-form'
type Status = 'OK' | 'NG'
type Schema = {
  status: Status
  price: number
  reason: string
}
const validateStatus = (status?: Status) => {
  if (!status) return '選択してください'
  return true
}
const App = () => {
  const {
    register,
    getValues,
    handleSubmit,
    formState: { errors },
  } = useForm<Schema>()
  // statusの値を参照するために、React-Hook-FormのgetValues APIを使用しないといけないため、
  // コンポーネント側にバリデーションロジックを書かないといけなくなっている
  const validatePrice = (price: number) => {
    const status = getValues('status')
    if (status === 'OK' && (Number.isNaN(price) || price <= 0))
      return 'OKの場合は1以上の価格を入力してください'
  }
  const validateReason = (reason: string) => {
    const status = getValues('status')
    if (status === 'NG' && !reason) return 'NGの場合は理由を入力してください'
  }
  return (
    <form onSubmit={handleSubmit((d) => console.log(d))}>
      <label>
        OK
        <input type="radio" value="OK" {...register('status')} />
      </label>
      <label>
        NG
        <input
          type="radio"
          value="NG"
          {...register('status', {
            validate: validateStatus,
          })}
        />
      </label>
      {errors.status?.message && <p>{errors.status?.message}</p>}
      <input
        type="number"
        {...register('price', {
          valueAsNumber: true,
          validate: validatePrice,
        })}
      />
      {errors.price?.message && <p>{errors.price?.message}</p>}
      <input
        {...register('reason', {
          validate: validateReason,
        })}
      />
      {errors.reason?.message && <p>{errors.reason?.message}</p>}
      <input type="submit" />
    </form>
  )
}
管理がしづらい

コンポーネントにバリデーションロジックが定義されてしまっているため、どのコンポーネントでどのバリデーションロジックが使用されているかを管理・把握するのが難しくなります。
その結果、プロジェクト内に複数同じようなバリデーションロジックが定義される等の問題が発生することがあります。

テストしづらい

バリデーションロジックを切り出せていないので、単体テストでバリデーションロジックをテストすることができず、インタラクションテスト等を用いらないとテストできなくなっています。

バリデーションロジックが使い回しづらい

バリデーションロジックを切り出せていないため、直接使い回しができません。

今回はこれらの問題に対応するため、バリデーションライブラリを使用することにしました。

余談

この例では、高階関数を使うことでバリデーションロジックを分離し、問題解決することも可能です。
ですが、常に最新の状態で再レンダリングを発生させる必要があるため、意図しないバグや、Controlled Componentsにしないといけないことから不要な再レンダリングを誘発し、パフォーマンスの悪化を招く可能性があるため推奨されません。
興味がある方はこちらのコード例をご参照ください。

​ ​

Zod の選定理由

今回バリデーションライブラリを選定するにあたっては、こちらの記事を参考に以下のことを考慮しました。 ​

考慮点

  • React-Hook-Form に対応しているか
  • Typescript 対応(schema から型を自動生成できるか)
  • github のスター数
  • ドキュメントが豊富か
  • 技術的挑戦(メンバーが未経験のライブラリを使いたい)

上記を考慮した結果、Yup・Zod・Superstruct が残り、「ドキュメントの豊富さ」・「生成される型の厳格さ」・「技術的挑戦」の観点を考慮して Zod を採用しました。

ドキュメントの豊富さ 型の厳格さ 技術的挑戦
Yup ×
Zod
Superstruct ×

React-Hook-Form × Zod の基本的な使用例

まず、事例に入る前に基本的な React-Hook-Form × Zod の使用例を公式の例を引用して紹介します。 ​

コード例

useForm の resolver に zodResolver + schema を渡すだけで、簡単にバリデーションを実現できます。

以下コードのデモ環境はこちら。 ​

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
const schema = z.object({
  name: z.string().min(1, { message: 'Required' }),
  age: z.number().min(10),
})
type Schema = z.infer<typeof schema>
const App = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<Schema>({
    resolver: zodResolver(schema), // zodResolver + schema
  })
  return (
    <form onSubmit={handleSubmit((d) => console.log(d))}>
      <input {...register('name')} />
      {errors.name?.message && <p>{errors.name?.message}</p>}
      <input type="number" {...register('age', { valueAsNumber: true })} />
      {errors.age?.message && <p>{errors.age?.message}</p>}
      <input type="submit" />
    </form>
  )
}

テスト

Zod の Schema を用いたテストも書いてみます。
正常系の場合は、parse した値をアサーションします。
異常系の場合は、toThrow メソッドを使用し、エラーをスルーしているか、スルーされたエラーの中に特定の文言があるかをアサーションしています。 ​

describe('schema', () => {
  describe('valid', () => {
    it('returns input', () => {
      const name = 'テスト太郎'
      const age = 10
      const input = {
        name,
        age,
      }
      const actual = schema.parse(input)
      expect(actual).toEqual(input)
    })
  })
  describe('inValid', () => {
    describe('name is empty string', () => {
      it('throws error', () => {
        const name = ''
        const age = 8
        const input = {
          name,
          age,
        }
        expect(() => schema.parse(input)).toThrow('Required')
      })
    })
    describe('age is less than 10', () => {
      it('throw error', () => {
        const name = 'テスト太郎'
        const age = 9
        const input = {
          name,
          age,
        }
        expect(() => schema.parse(input)).toThrow()
      })
    })
  })
})

実際にプロジェクトで実装した事例

ここからは実際にプロジェクトで実装した事例を紹介していきます。 ​

事例 1: 別々の入力値としている郵便番号を 1 つの文字列に変換する

まずは簡単な事例として、Zod のtransform APIを使用して、handleSubmit で受け取れるフォームの値を任意の形に変換した事例を紹介します。

以下コードのデモ環境はこちら

コード例

3 桁、4 桁で別々に入力された郵便番号を transform API を使って変換し、handleSubmit で 7 桁の 郵便番号 として受け取っています。 ​

import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
/** 郵便番号の上三桁(郵便句番号) */
const firstThreeDigitsSchema = z
  .string()
  .regex(/^\d{3}$/, { message: '3桁の数値を入力してください。' })
/** 郵便番号の下四桁(町域番号) */
const lastFourDigitsSchema = z
  .string()
  .regex(/^\d{4}$/, { message: '4桁の数値を入力してください。' })
export const zipCodeSchema = z
  .object({
    firstThreeDigits: firstThreeDigitsSchema,
    lastFourDigits: lastFourDigitsSchema,
  })
  // 7桁の郵便番号を生成
  .transform(
    ({ firstThreeDigits, lastFourDigits }) => firstThreeDigits + lastFourDigits,
  )
type InputSchema = z.input<typeof zipCodeSchema> // transform前で推論
type OutputSchema = z.output<typeof zipCodeSchema> // transform後で推論
const App = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<InputSchema>({
    resolver: zodResolver(zipCodeSchema),
  })
  return (
    <form
      onSubmit={handleSubmit((_d) => {
        // 7桁の郵便番号を受け取れる
        // handleSubmitの引数はInputSchemaで推論されてしまうので、OutputSchemaに型アサーションする必要がある
        const d = (_d as unknown) as OutputSchema
        console.log(d)
      })}
    >
      <input type="text" maxLength={3} {...register('firstThreeDigits')} />
      {errors.firstThreeDigits?.message && (
        <p>{errors.firstThreeDigits?.message}</p>
      )}
      -
      <input type="text" maxLength={4} {...register('lastFourDigits')} />
      {errors.lastFourDigits?.message && (
        <p>{errors.lastFourDigits?.message}</p>
      )}
      <input type="submit" />
    </form>
  )
}

​ z.input, z.output を使用することで、それぞれ変換前後の型を取得することができます。

​ 注意しなければいけないのは、handleSubmit 内で受け取れる値は変換前の型で推論されてしまうことです。

これは、React-Hook-Form 側の問題のようで、既にIssueが発行されており、v8 で修正予定のようですので、現在は OutputSchema で型アサーションせざるえない状態です。 ​

テスト

transform API を使うことで handleSubmit 内で実行しなければいけない変換ロジックを Schema に寄せることができるので、バリデーションとまとめてテストを行うことができます。 ​

describe('parse', () => {
  describe('when success', () => {
    const data = {
      firstThreeDigits: '123',
      lastFourDigits: '4567',
    }
    const expected = '1234567'
    it('adds sevenDigits', () => {
      const actual = zipCodeSchema.parse(data)
      expect(actual).toEqual(expected)
    })
  })
  describe('when an error occurs in the first three digits', () => {
    const data = {
      firstThreeDigits: '1234',
      lastFourDigits: '5678',
    }
    it('throws error', () => {
      expect(() => zipCodeSchema.parse(data)).toThrow(
        '3桁の数値を入力してください',
      )
    })
  })
  describe('when an error occurs in the first three digits', () => {
    const data: InputSchema = {
      firstThreeDigits: '123',
      lastFourDigits: '45678',
    }
    it('throws error', () => {
      expect(() => zipCodeSchema.parse(data)).toThrow(
        '4桁の数値を入力してください',
      )
    })
  })
})

事例 2: フォームの項目を組み合わせてバリデーションを行う

次に、Zod のrefine APIを使用して、各フォームの項目を組み合わせたバリデーションを作成した事例を紹介します。

コード例

以下の例では、status の値によって、適用するバリデーションを分けています。 ​ 以下コードのデモ環境はこちら

import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
export const schema = z
  .object({
    status: z.enum(['OK', 'NG'], {
      errorMap: () => ({ message: '選択してください。' }),
    }),
    price: z.number().or(z.nan()),
    reason: z.string(),
  })
  .refine(
    (val) => val.status === 'NG' || (!Number.isNaN(val.price) && val.price > 0),
    {
      message: 'OKの場合は1以上の価格を入力してください',
      path: ['price'],
    },
  )
  .refine((val) => val.status === 'OK' || val.reason, {
    message: 'NGの場合は理由を入力してください',
    path: ['reason'],
  })
type InputSchema = z.input<typeof schema>
const App = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<InputSchema>({
    resolver: zodResolver(schema),
  })
  return (
    <form onSubmit={handleSubmit((d) => console.log(d))}>
      <label>
        OK
        <input type="radio" value="OK" {...register('status')} />
      </label>
      <label>
        NG
        <input type="radio" value="NG" {...register('status')} />
      </label>
      {errors.status?.message && <p>{errors.status?.message}</p>}
      <input
        type="number"
        {...register('price', {
          valueAsNumber: true,
        })}
      />
      {errors.price?.message && <p>{errors.price?.message}</p>}
      <input {...register('reason')} />
      {errors.reason?.message && <p>{errors.reason?.message}</p>}
      <input type="submit" />
    </form>
  )
}

​ status が OK の場合は、price のみ 1 円以上の入力を必須としています。 status が NG の場合は、reason のみ 1 文字以上の入力を必須としています。

refine API は、カスタムバリデーションを自由に組むことができ、第 1 引数に渡したコールバック関数で Falsy な値を返せばエラーになるだけの比較的自由度の高い API です。

第 2 引数には、オブジェクトを渡すことができ、エラーメッセージや、エラーを発行する path を設定することができます。

テスト

書いたテストを紹介します。 ​

describe('parse', () => {
  describe('when valid', () => {
    describe('when status is OK and price is positive int', () => {
      it('returns input', () => {
        const input = {
          status: 'OK',
          price: 500,
          reason: '',
        }
        const actual = schema.parse(input)
        expect(actual).toEqual(input)
      })
    })
    describe('when status is NG and reason is not empty string', () => {
      it('returns input', () => {
        const input = {
          status: 'NG',
          price: NaN,
          reason: 'テスト',
        }
        const actual = schema.parse(input)
        expect(actual).toEqual(input)
      })
    })
  })
  describe('when inValid', () => {
    describe('when status is OK and price is NaN', () => {
      it('throws error', () => {
        const input = {
          status: 'OK',
          price: NaN,
          reason: 'テスト',
        }
        expect(() => schema.parse(input)).toThrow(
          'OKの場合は1以上の価格を入力してください',
        )
      })
    })
    describe('when status is OK and price is 0', () => {
      it('throws error', () => {
        const input = {
          status: 'OK',
          price: 0,
          reason: 'テスト',
        }
        expect(() => schema.parse(input)).toThrow(
          'OKの場合は1以上の価格を入力してください',
        )
      })
    })
    describe('when status is NG and reason is empty string', () => {
      it('throws error', () => {
        const input = {
          status: 'NG',
          price: 1,
          reason: '',
        }
        expect(() => schema.parse(input)).toThrow(
          'NGの場合は理由を入力してください',
        )
      })
    })
  })
})

事例3: 複数の項目に対してエラーを設定する

最後に、Zod のsuperRefine APIを使用して、フォーム内の複数の項目にエラーを設定した事例を紹介します。

コード例

以下の例では、押された submit ボタンに応じて、バリデーションの内容を変化させ、エラーになった場合は price・reason どちらにもエラーメッセージを表示させています。 ​ 以下コードのデモ環境はこちら

import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
export const schema = z
  .object({
    status: z.enum(['OK', 'NG'], {
      errorMap: () => ({
        message: '選択してください。',
      }),
    }),
    price: z.number().or(z.nan()),
    reason: z.string(),
  })
  .superRefine((val, ctx) => {
    if (val.status === 'OK' && (Number.isNaN(val.price) || val.price <= 0)) {
      ctx.addIssue({
        code: 'custom',
        message: 'OKの場合は1以上の価格を入力してください',
        path: ['price'],
      })
      ctx.addIssue({
        code: 'custom',
        message: 'NGの場合は理由を入力してください',
        path: ['reason'],
      })
    }
  })
  .superRefine((val, ctx) => {
    if (val.status === 'NG' && !val.reason) {
      ctx.addIssue({
        code: 'custom',
        message: 'OKの場合は1以上の価格を入力してください',
        path: ['price'],
      })
      ctx.addIssue({
        code: 'custom',
        message: 'NGの場合は理由を入力してください',
        path: ['reason'],
      })
    }
  })
type Schema = z.input<typeof schema>
const App = () => {
  const {
    register,
    setValue,
    handleSubmit,
    formState: { errors },
  } = useForm<Schema>({
    resolver: zodResolver(schema),
  })
  return (
    <form
      onSubmit={handleSubmit((d) => {
        console.log(d)
      })}
    >
      <input
        type="number"
        {...register('price', {
          valueAsNumber: true,
        })}
      />
      {errors.price?.message && <p>{errors.price?.message}</p>}
      <input {...register('reason')} />
      {errors.reason?.message && <p>{errors.reason?.message}</p>}
      <input
        type="submit"
        value="OK"
        onClick={() => setValue('status', 'OK')}
      />
      <input
        type="submit"
        value="NG"
        onClick={() => setValue('status', 'NG')}
      />
    </form>
  )
}

​ superRefine API を使うと、第 1 引数に渡したコールバック関数の第 2 引数に ctx を受け取ることができ、そこから addIssue メソッドを使ってエラーを設定することができます。 また、addIssue を複数実行すればその数だけエラーを設定することができます。 ​

テスト

書いたテストを紹介します。 今回は異常系で 2 つのエラーメッセージが表示されていることをアサーションしています。 ​

describe('when valid', () => {
  describe("status is 'OK'", () => {
    const input = {
      status: 'OK',
      price: 1,
      reason: '',
    }
    it('returns input', () => {
      const actual = schema.parse(input)
      expect(actual).toEqual(input)
    })
  })
  describe("status is 'NG'", () => {
    const input = {
      status: 'NG',
      price: NaN,
      reason: 'テスト',
    }
    it('returns input', () => {
      const actual = schema.parse(input)
      expect(actual).toEqual(input)
    })
  })
})
describe('when invalid', () => {
  describe("status is 'OK' and price is NaN", () => {
    const input = {
      status: 'OK',
      price: NaN,
      reason: '',
    }
    it('throws error', () => {
      expect(() => schema.parse(input)).toThrow(
        'OKの場合は1以上の価格を入力してください',
      )
    })
    it('throws error', () => {
      expect(() => schema.parse(input)).toThrow(
        'NGの場合は理由を入力してください',
      )
    })
  })
  describe("status is 'NG' and reason is empty string", () => {
    const input = {
      status: 'NG',
      price: 0,
      reason: '',
    }
    it('throws error', () => {
      expect(() => schema.parse(input)).toThrow(
        'OKの場合は1以上の価格を入力してください',
      )
    })
    it('throws error', () => {
      expect(() => schema.parse(input)).toThrow(
        'NGの場合は理由を入力してください',
      )
    })
  })
})

おわりに

初めて実務で使ってみて、選定当時には見えていなかった React-Hook-Form × Zod の便利な使用法がいろいろと見えてきました。

個人的には、やはりバリデーションロジックを分離できたことで、どのパターンでも比較的簡単にバリデーションロジック・変換ロジックの部分を単体テストで書くことができるようになったのが最高の DX でした。

今後は、「環境変数の読み込み」・「外部データの parse」等のように、React-Hook-Form と組み合わせる使用法以外でも、積極的に Zod を導入していこうと思っています。

最後に、バイセルではエンジニアを募集しています。少しでも気になった方はぜひご応募お待ちしています。

herp.careers

明日の バイセルテクノロジーズ Advent Calendar 2022 は 金子さんによる 「Render Props Callback Hell の解消」 です。