👷

React Hook FormとZodでユーザーが入力した数値を扱う

2024/03/11に公開

以前以下の記事でユーザー入力による数値の扱いについて、制御コンポーネントで対応する方法について書きました。
https://zenn.dev/funteractiveinc/articles/component-input-number

しかし以前書いた方法でもなかなかうまく対処できないことがあり、最近は非制御コンポーネントを使用することが多くなりました。
またReact Hook FormとZodとともに扱うことが多くなったのでその方法についてコードとともに書きます。

作るもの

試行錯誤したリポジトリ

試行錯誤したリポジトリを載せておきます。
説明など一切書いてないので参考までにお願いします。
記事と直接関係ないものも入っています。

https://github.com/052hide/zod-playground

コードと説明

コード内にコメントで説明を書きます。
実際のコードから一部省略したりしています。

汎用入力コンポーネント

// Input.tsx

import { type ComponentProps, type Ref, forwardRef } from 'react'
import type { InputTextFieldProps, InputTextFieldRef } from './type'

/*
  非制御コンポーネントとして汎用ユーザー入力コンポーネントを作成する
  typeにはnumberは使用しない (この例ではtext固定としている)
*/
const Base = (
  { ...props }: Omit<ComponentProps<'input'>, 'type' | 'value' | 'onChange' | 'className'>,
  ref: Ref<HTMLInputElement>
) => {
  return <input ref={ref} type={'text'} {...props} />
}
export const Input = forwardRef(Base)

Formコンポーネント

// SampleForm/SampleForm.tsx

import type { I, C, O } from './type'

import { zodResolver } from '@hookform/resolvers/zod'
import { FormProvider, useForm } from 'react-hook-form'
import { FORM_SCHEMA } from './const'
import { HogeFormField } from './formFields'

export const SampleForm = () => {
  /*
    useFormのgenericsで入力値の型 (I)、変換後の型 (O) を指定する
    これによりFormの操作はstring型、バリデーション後はnumber型として扱うことができる
  */
  const formMethods = useForm<I, C, O>({ resolver: zodResolver(FORM_SCHEMA) })

  const submitHandler = (data: TransformedValues) => {
    /*
      APIを呼んだりする
      submitHandler内ではバリデーション後の型 ({ hoge: number }) で扱える
    */
  }

  return (
    /*
      FormProviderを使用することで、useFormContextが使えるようになる
      これによりFormコンポーネント自体の肥大化を防ぐことができる
    */
    <FormProvider {...formMethods}>
      <form
        onSubmit={formMethods.handleSubmit(submitHandler)}
      >
        <HogeFormField />
        <button type="submit">Submit</button>
      </form>
    </FormProvider>
  )
}
// SampleForm/const.ts

import { z } from 'zod'
import { HOGE_SCHEMA } from './formFields/HogeFormField/const'

export const FORM_SCHEMA = z.object({ hoge: HOGE_SCHEMA })
// SampleForm/type.ts

import { z } from 'zod'
import type { FORM_SCHEMA } from './const'

// z.inputでFORM_SCHEMAの入力値の型、z.outputで変換後の型を取得できる
export type I = z.input<typeof FORM_SCHEMA> // { hoge: string }
export type C = unknown
export type O = z.output<typeof FORM_SCHEMA> // {hoge: number }

FormFieldコンポーネント

// SampleForm/formFields/HogeFormField/HogeFormField.tsx

import type { ComponentProps } from 'react'
import type { I, C, O } from '../../type'

import { useFormContext } from 'react-hook-form'
import { Input } from '~/components/Input'
import { FIELD_KEY, FIELD_NAME, STRING_TO_NUMBER_SCHEMA, HOGE_SCHEMA } from './const'

export const HogeFormField = () => {
  const { formState, register, setValue } = useFormContext<I, C, O>()

  const { onBlur, ...registration } = register(FIELD_KEY)

  const handleFocus: ComponentProps<typeof Input>['onFocus'] = (e) => {
    const safeParsed = STRING_TO_NUMBER_SCHEMA.safeParse(e.target.value)
    if (safeParsed.success) {
      /*
        onFocusでカンマ等を除去した数値を表示する
        スキーマで定義した変換処理をそのまま使用できる
      */
      setValue(FIELD_KEY, `${safeParsed.data}`)
    }
  }

  const handleBlur: ComponentProps<typeof Input>['onBlur'] = (e) => {
    /**
     * HOGE_SCHEMAを使用すると数値のバリデーションチェックNGの場合にフォーマットされないため数値チェック前のスキーマでバリデーションを行う
     */
    const safeParsed = STRING_TO_NUMBER_SCHEMA.safeParse(event.target.value)
    if (safeParsed.success) {
      /*
        onBlurでカンマを付けたりする
      */
      setValue(safeParsed.data.toLocaleString())
    }
    onBlur(e)
  }

  return (
    <div>
      <label for={FIELD_KEY}>{FIELD_NAME}</label>
      <Input {...registration} inputMode={'numeric'} onFocus={handleFocus} onBlur={handleBlur} />
      { formState.errors[FIELD_KEY]?.message && <p>{formState.errors[FIELD_KEY]?.message}</p> }
    </div>
  )
}
// SampleForm/formFields/HogeFormField/const.tsx

export const FIELD_KEY = 'hoge'
export const FIELD_NAME = 'ほげ'

const RANGE = { min: 10_000, max: 1_000_000_000, } as const

export const STRING_TO_NUMBER_SCHEMA =
  z.string({ required_error: '必須です', })
    .trim()
    .min(1, '必須です')
    .transform((v) => {
      /*
        数値に変換する
        カンマ除去や全角から半角への変換などをformatFnで行う
      */
      return Number(formatFn(v))
    })
    .refine((v) => !isNaN(v), { message: '数値を入力してください', })

/*
  stringで受取り、numberに変換してバリデーションを行う
*/
export const HOGE_SCHEMA = ({ formatFn }: { formatFn: (v: string) => string }) =>
  STRING_TO_NUMBER_SCHEMA
    .pipe<
      /*
        型を指定することでそれ以外の型を受け付けないようにする
        この場合pipeの引数にz.string()とかを指定するとgenericsで指定した型と異なるためエラーとなる
      */
      z.ZodType<z.output<ReturnType<typeof STRING_TO_NUMBER_SCHEMA>>>
    >(
      /*
        numberとしてバリデーションを行う
      */
      z
        .number()
        .int({ message: '整数で入力してください' })
        .positive({ message: '正の数で入力してください'})
        .min(RANGE.min, { message: `${RANGE.min}以上を入力してください`, })
        .max(RANGE.max, { message: `${RANGE.max}以下を入力してください` })
    )

できたもの(上に載せたのと同じ)

感想

最終的に複雑になってしまった印象はあるが、ZodやReact Hook Formが素晴らしく個人的には悪くないものができたと思っています。
実際には複数のコンポーネントから使用できるよう一部のスキーマの共通化をしているがこれがとても難しいです。
今回は書かなかったけど必須項目と任意項目のスキーマの一部を共通化したいがなかなかうまくいかないです。
いい方法があれば教えてください。

Discussion