🐕

ReactHookFormでschemaを組み合わせてFormを作る

2022/03/16に公開
1

ReactHookFormyupのschemaを組み合わせてフォームを作る方法です。
yupと書きましたがzodでも同じことは出来るはずです。

サンプルのコードはこちら
https://github.com/iteduki/ReactHookForm-book-example

この記事ではReactHookFormの基本的な使い方は知っている前提です。

ReactHookFormとyupを組み合わせる

ReactHookFormではresolversを使うことでスキーマを元にバリデーションを行うことが出来ます

https://www.npmjs.com/package/@hookform/resolvers

使い方はnpmリポジトリのQuickStartにある通り、useFormのオプションに resolver: yupResolver(schema)を与えてあげるだけです。

const { register, handleSubmit } = useForm({
  resolver: yupResolver(schema),
});

schemaを分割する

Yup.object()自体は展開できませんが、オブジェクトのフィールドをyupで定義したスキーマにすることで、各スキーマを展開することで1つのスキーマを作ることが出来ます。

具体的には下記のようなコードです。

const userNameSchema = {
  name: Yup.string().required('名前を入力してください')
}
const userEmailSchema = {
  email: Yup.string().required('メールアドレスを入力してください')
}

const schema = Yup.object({
  ...userNameSchema,
  ...userEmailSchema
})

こうしてスキーマを分割しておくことで、スキーマを持ったそれぞれのコンポーネントを組み合わせてフォームを作ることが出来ます。

例えばあるフォームでは名前しかいらないときは、schemaには上記のuserNameSchemaだけ含めればいいといった具合です。

schemaから型を作る

yupでは定義したスキーマから型を作ることが出来ます

const userNameSchema = {
  name: Yup.string().required('名前を入力してください')
}
const yupObject = Yup.object(userNameSchema)
type UserNameFormValues = Yup.InferType<typeof yupObject>
// => type UserNameFormValues = { name: string }

string以外にもnumberboolean,arrayなどが表現できます。
requiredをつけないとstring | undefinedになります

ここで生成できる型とReactHookFormを組み合わせて型の検証を生かしたフォームを作っていきます。

型からnameを作る

ReactHookFormが提供するPath型は、フォームの型を受け取り、有効なフィールド名のリテラル型を返してくれます。

import type { Path } from 'react-hook-form'

type NameType = Path<{ firstName: string; lastName: string; }>
// => type NameType = 'firstName' | 'lastName'

type UserNameType = Path<{ users: { firstName: string; lastName: string }[] }>
// => type UserNameType = "users" | `users.${number}` | `users.${number}.firstName` | `users.${number}.lastName`

配列やオブジェクトでもきちんとformに設定できるnameの型を返してくれます。

これを利用してinputのname属性を型で検証できるようなコンポーネントを作ってみます。

TextInput

まずはコードを載せます。

import type { InputHTMLAttributes } from 'react'
import type { FieldValues, Path } from 'react-hook-form'
import { useFormContext } from 'react-hook-form'

export interface TextInputProps<T> extends Omit<InputHTMLAttributes<HTMLInputElement>, 'name'> {
  name: Path<T>
}
export const TextInput = <T extends FieldValues = never>({
  name,
  ...props
}: TextInputProps<T>): ReturnType<React.VFC> => {
  const { register } = useFormContext()

  return <input type="text" {...register(name)} {...props} />
}

先程のPath<T>で作ったnameのみを受け取るようなTextInputコンポーネントです。

PathとともにimportしているFieldValuesuseFormの型引数と同じ型で、実体はRecord<string, any>です。デフォルトをneverにすることで型を指定しないとエラーになるようにしています。

const userNameSchema = {
  firstName: Yup.string().required('名前を入力してください')
  lastName: Yup.string().required('姓を入力してください')
}
const yupObject = Yup.object(userNameSchema)
type UserNameFormValues = Yup.InferType<typeof yupObject>

export const UserName: React.VFC = () => {
  return (
    <>
      <TextInput<UserNameFormValues> name="lastName">
      <TextInput<UserNameFormValues> name="firstName">
    </>
  )
}

先程のnameのスキーマと組み合わせるとこんな感じになります。
与えた型に含まれるname以外を入れるとエラーになり、nameのタイポを防ぐことが出来ます。

このようにyupのスキーマとそこから生成される型とReactHookFormが提供する型を組み合わせることで型の恩恵を受けたフォーム開発をすることが出来ます。
スキーマはコンポーネントごとに閉じているのでコンポーネント単位で組み合わせたりテストしたりすることが出来ます。

Discussion

nap5nap5

スキーマを分割しておく

バリデーションを伴うスキーマであれば、validateXXXのようにしてみても面白いかもです。

import { Err, Ok, Result } from 'neverthrow'
import { z } from 'zod'

export const LastNameSchema = z.string().min(1, '必須入力です')

export const validateLastName = (data: unknown): Result<string, Error> => {
  const parsed = LastNameSchema.safeParse(data)
  if (!parsed.success) {
    return new Err(
      new Error(parsed.error.message, {
        cause: parsed.error,
      })
    )
  }
  return new Ok(parsed.data)
}

デモコードです。

https://codesandbox.io/p/sandbox/my-project-qnhh8y?file=%2Fsrc%2Ffeatures%2Fuse-control%2Fcomponents%2FForm%2FForm.tsx

簡単ですが、以上です。