Reactでウェブフォームを作る2021

6 min read読了の目安(約5900字

Webのフォームは、いつでもベストプラクティスを悩むものの一つです。React を使うとして完全に自作でやるのか?それともフォームライブラリを使うか?フォームライブラリならどれを使うか?

今の時代 Formik を選ぶ理由はありませんが、React Hook FormReact Final Form のどちらを使うかはとても悩ましいです。

React Hook Form は利用経験者・採用実績が多い、速度が速いなど様々な利点はありますが、React 哲学に反する作りなどクセの強さが難点です。あと良くも悪くも利用シーンが豊富でドキュメントも豊富で迷子になりがちです。

React Final Form は Final Form の React wrapper です。個人的にはこちら React 的使いやすさに反すると感じてること、React Final Form として見たときにドキュメンテーションが貧弱すぎることなどがあまり好きじゃない点です(あくまで個人の感覚です)。

以前の React Hook Form と React Final Form だとかなり悩みますが、React Hook Form v6(や予定されてるv7)を見る限りは迷うことなく React Hook Form でいいかなと思います(あくまで僕の判断です)。

過去に破壊的変更で悩まされたりしましたが、React Hooks 全振りで行く現代になった以上、今までほどめんどくさいことにならないだろうと考えています(あくまで僕の判断です)。

また、これらのライブラリを使うときにバリデーションスキーマをどうするか?という問題もあります。Yup よりも Zod の方がサイズが小さいとか TypeScript フレンドリーだとかありますが、ぶっちゃけウェブフォームで使うバリデーションスキーマはさほど TypeScript は重要ではありません(個人の意見です)。

そもそもバリデーションスキーマという、型の概念とバチバチに競合するものを無理やり TypeScript に当てはめるのは得策ではありません。どうせバリデーションスキーマは React Hook Form に食わせるためだけに使うので、その後のデータの厳密な型はさほど重要ではありません。

個人的に唯一意味があるとすれば、TypeScript の型定義からスキーマが生成されるのであれば良いと思いますが、そのときに TypeScript の型で表現できない情報をどうやってスキーマ定義するか?という問題が残ります。個人的には decorator は絶対に使いたくありません(個人の意見です)。

またバリデーションスキーマ自体不要なのでは?という考え方もあります。どうせ細かい定義は、公式のメソッドではなく自作せざるを得ないことも多いです。

正直、ものすごく悩んだのですが、今回は Yup を採用することにしました。

そこで、個人的な感想や意見としては、いま選ぶなら React Hook Form + Yup としました。

React Hook Form を使う

npm i react-hook-form なり yarn add react-hook-form なりでインストールをします。

# npm
npm i react-hook-form

# yarn
yarn add react-hook-form

使い方としては、https://react-hook-form.com/jp/get-started を読めばいいのですが、一つ難点があります。機械翻訳なのかとても日本語としてクセの強い文章かつ一部が未翻訳なので、英語アレルギーがなければ、素直に原文の https://react-hook-form.com/get-started を読んだ方がいいかもしれません。

さて、サンプル一発目では form input select など生エレメントが出てきます。もちろん公式ドキュメントとしては、こういう書き方になるのがとても正しいのですが、生エレメント直書きしたフォームコンポーネント一つを作ることはまずないでしょう。そういうような事例ならそもそもにしてフォームライブラリを採用する必要性もあまりありません。

どういう構造にするか選択する

現実的な使い方としては

  1. material-ui など既存のUIライブラリを使う
  2. CSS in JS や Tailwind CSS などを使い自分たちでコンポーネントを作って、それを import して使う

の2択でしょう。

さて、ここでさらにもう一つ考えるべきことを増やします。2番目の場合、どういうインターフェースのコンポーネントに設計するか?という問題があります。React Hook Form にバチバチに最適化したコンポーネントにしてもいいやという判断ができるならば、よくある onChange だの value を生やさないという考え方もできます。React Hook Form の control を引数で渡すか、Context経由で受け取ることになるでしょう。仕組みと密結合してしまいますが、コードはとてもシンプルかつ、高性能なものになります。

このとき、コンテキスト経由にせよプロパティ経由にせよ、中途半端に抽象的な考え方を挟むくらいなら、React Hook Form と一蓮托生の覚悟で React Hook Form の Control に完全に依存した設計になることでしょう。当然のことながらテストは面倒になります。

  • 2番目の選択肢をさらに細かく分ける
    1. React Hook Form に依存したインターフェースを採用する
    2. まっとうなインターフェースを採用する

まっとうなインターフェースを採用する場合、広く知られたものと互換性をもたせるというのはとても良い考え方です。既存の好きなライブラリとインターフェースをなるべく揃えてみるといいでしょう。どれと合わせるの?っていう問題はありますが、好みやチームメンバーの慣れでいいと思います。MaterialUI を触ったことがある人は多いでしょうから、そういったものと極力合わせるといいかもしれません。

React Hook Form に依存したインターフェースを採用する

import React from 'react'
import { useController, useFormContext } from 'react-hook-form'

type Props = {
  defaultValue: string
  name: string
}

export const TextField: React.VFC<Props> = ({ defaultValue, name }) => {
  const { control } = useFormContext()
  const { field } = useController({control, name, defaultValue})

  return <input type="text" {...field} />
}

最小限にするならこれくらいのコードになるでしょうか?実際にはここにCSSを当てたり、他のプロパティを設定することになるでしょう。

フォーム要素の名前(というかキーである)name 及び、初期値 defaultValue と、コンテキストから取得した controluseController に食わせます。そうすると fieldref onChange onBlur value name が含まれているので、そのまま input のプロパティとして渡すだけです。

この TextField コンポーネントでやることはほとんど React Hook Form の useController の戻り値がやってくれます。input の入力ステート管理、変更管理、バリデーションなどです。

まっとうなインターフェースを採用する

import { useCallback } from 'react'

type Props = {
  value: string
  onChange: (ev: ChangeEvent<HTMLInputElement>) => void
  onBlur: () => void
}

export const TextField: React.VFC<Props> = ({ value, onChange, onBlur }) => {
  return <input type="text" value={value} onChange={onChange} onBlur={onBlur} />
}

Reactで一番一般的な、コントロールド(制御された)入力フォームです。ただ onBlur をわざわざ設定するのはあまりないかもしれません。

この TextField の外側で、ステート管理をしてもいいですし、React Hook Form を動かしてもいいです。少しだけ React Hook Form 依存のコードよりは面倒ですが、テスタビリティの良さと、React Hook Form と心中しなくて済むという大いなる利点があります。

まっとうなインターフェースや既存のライブラリを使う

React Hook Form では useController とは別に、制御するための仕組みとして Contoller コンポーネントがあり、それを使うのが一般的です。より正確にいうと useControllerController よりも後に登場したものです。

import { Controller } from 'react-hook-form'

...
   <Controller
     name="hoge"
     defaultValue="初期値"
     render={({ value, onBlur, onChange }) => (
       <TextField
         value={value}
         onChange={onChange}
         onBlur={onBlur}
       />
     )}
   />
...

ちなみに Controller は、FormContext の中で実行したわけでなければ useForm が生成する control を直接渡す必要があります。

外側に制御本体があるとして

import { useForm, FormProvider} from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import * as y from 'yup'

...
  const validationSchema = y.objetct({
    hoge: y.string().min(5).max(20)
    // ここでは最小5文字、最大20文字の、文字列と定義している
  })

  const methods = useForm({
    resolver: yupResolver(validationSchema)
    mode: "onBlur"
    // 入力欄から離れた(onBlur)タイミングでバリデーションが行われる
  })

  return (
    <FormProvider {...methods}>
      ...
    </FormProvider>
  )

このようなコードになるでしょう。この場合、FormProvider により Controller は勝手にコンテキストから必要なもの control を取得してくれます。

まとめ

React Hook Form は実際のところ、多彩なインターフェースを持ち、多種多様な使い方ができてしまうため、ドキュメントのどこを読めばいいかわからなくなることがあります。

  • useForm
  • useForm と違うコンポーネントで Controller を使うか useContoller を使うなら FormProvider
  • ControlleruseController のどちらか(あるいは両方)

基本的な登場人物はこれらです。

あとは、yup を使ってバリデーションスキーマを書き yupResolver で React Hook Form と接続するだけです。ちなみに @hookform/resolvers は 2021年3月7日現時点では Next.js と組み合わせたときに最新版だとコンパイルエラーが生じるため、古いバージョン 1.2.0 に固定するといいでしょう。

see. Build error after upgrade to 1.3.1 in Next.js · Issue #100 · react-hook-form/resolvers

まぁ、この問題もいずれは解決するはずですが、そういうこともあると思ってください。