🦔

React Hook Form + zodで複雑な条件分岐を含んだschemaを書く

2023/03/06に公開

React Hook Formとzodを使えばバリデーションをいい感じに管理できるので最近使いはじめたのですが、○○がチェックされていたら△△は必須項目に、とか、チェックボックスが○個以上チェックされていたら別の項目を必須に...などなど、複雑な条件のSchemaを書く場合にハマったので備忘録を兼ねてポストします。

単純な条件の場合

unionを使う

項目にチェックが入っていたら、とかなら割と簡単でz.union()z.literal()の組み合わせでユニオン型を作ることができる。

const selectSchema = z.union([
  z.object({
    ageInLaw: z.literal(false),
    parentName: z
      .string()
      .max(30, '名前が長すぎます')
      .optional(),
  }),
  z.object({
    ageInLaw: z.literal(true),
    parentName: z
      .string()
      .min(
        1,
        '未成年の場合は必ず入力してください'
      )
      .max(30, '名前が長すぎます'),
  }),
])

もう少し複雑な場合

refine / superRefineを使う

literalで表現できない条件の場合はrefineを使って表現できる。

export const checkMovieCount = (
  movies: { value: boolean }[] | undefined
) => {
  return (
    (movies &&
      movies.filter((item) => item.value)
        .length) ||
    0
  )
}

const checkboxSchema = z
  .object({
    favoriteMovie: z.array(
      z.object({
        value: z.boolean(),
      })
    ),
    reason: z
      .string()
      .max(200, '200文字以内で記入してください')
      .optional(),
  })
  .refine(
    ({ favoriteMovie, reason }) =>
      // 第二引数がfalseを返す条件の場合
      !(
        checkMovieCount(favoriteMovie) >= 3 &&
        reason === ''
      ),
    // 第3引数のバリデーションエラーを返す
    {
      path: ['reason'],
      message:
        '3つ以上選ばれた方は理由を記入してください',
    }
  )

ここでハマったのはrefineの第二引数。ノリでここをtrueになる条件で書いてて全然うまく反映されないと思ってたら、「falseの場合にバリデーションエラーが返る」だった。
この場合、元々理由欄がオプショナルで指定していたところを、チェックが3つ以上入ってて、かつ、理由欄に記入がなかったらエラーを返す=必須項目に変更する、という条件になる。
falseが欲しいので、「チェックが3つ以上入ってて、かつ、理由欄に記入がなかったら」を反転させている。

React Hook Formで実装

ほかにもいろいろスキーマを足してReact Hook Formに突っ込んだのがこちら

import { FC } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import {
  checkMovieCount,
  schema,
  Schema,
} from './schema'

const movies = [
  { label: 'ほげほげ', value: false },
  { label: 'ふがふが', value: false },
  { label: 'ほげほげ2', value: false },
  { label: 'ふがふが2', value: false },
  { label: 'ほげほげ3', value: false },
  { label: 'ふがふが3', value: false },
]

export const Form: FC = () => {
  const {
    register,
    watch,
    handleSubmit,
    formState: { errors, isValid },
  } = useForm<Schema>({
    mode: 'onChange',
    resolver: zodResolver(schema),
  })
  const movieWatcher = watch('favoriteMovie')

  const onSubmit = (data: Schema) => {
    console.log(data)
  }
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <div>
          <label htmlFor="name">
            お名前
            <input
              type="text"
              id="name"
              placeholder="お名前"
              {...register('name')}
            />
          </label>
          {errors.name && (
            <p>{errors.name.message}</p>
          )}
          <label htmlFor="nickname">
            ニックネーム
            <input
              type="text"
              id="nickname"
              placeholder="ニックネーム"
              {...register('nickname')}
            />
          </label>
          {errors.nickname && (
            <p>{errors.nickname.message}</p>
          )}
        </div>
        <div>
          <label htmlFor="ageInLaw">
            未成年ですか?
            <input
              id="ageInLaw"
              type="checkbox"
              {...register('ageInLaw')}
            />
          </label>
          <label htmlFor="parentName">
            両親の名前
            <input
              id="parentName"
              type="text"
              placeholder="両親の名前"
              {...register('parentName')}
            />
          </label>
          {errors.parentName && (
            <p>{errors.parentName.message}</p>
          )}
        </div>
        <div>
          {movies.map((movie, index) => (
            <label key={index}>
              {movie.label}
              <input
                type="checkbox"
                {...register(
                  `favoriteMovie.${index}.value`
                )}
              />
            </label>
          ))}
        </div>
        {checkMovieCount(movieWatcher) >= 3 && (
          <>
            <label htmlFor="reason">
              <input
                type="text"
                id="reason"
                placeholder="理由"
                {...register('reason')}
              />
            </label>

            {errors.reason && (
              <p>{errors.reason.message}</p>
            )}
          </>
        )}
        <button type="submit" disabled={!isValid}>
          Submit
        </button>
      </div>
    </form>
  )
}

resolverzodResolverにしたところ以外はよく見るやつ。
バリデーションが外形化してるのでスッキリしていい感じになった。

多分チェックボックスをmapで回してるところはuseFieldArrayを使ったほうがいいんだろうな、なんて思いながらちゃんと調べるの時間かかりそうなので一旦これにて。
(スタイルは何もあててません。)

Discussion