React Hook Formとは?煩雑なフォームのstate管理を楽に実装する!

f:id:takuyawww1101:20210423084250p:plain

目次

  • はじめに
  • Reactを使用したフォーム設計パターンについて
  • React Hook Formとは ?
  • React Hook Formの基本機能の紹介
  • React Hook Formのユースケース
  • 最後に

はじめに

こんにちは、株式会社スタメンでエンジニアをしています、ワカゾノです。 Rails、Reactを使用して、弊社プロダクトTUNAGの機能開発を行っています。

直近のプロジェクトにおいて、Reactでフォームを実装する必要がありました。 要件としては、下記のようになります。

  • 新規作成時、編集時のフォームをerbから、Reactへリプレイス
    • 1画面毎に3 ~ 6つのフォームが存在、それを10数画面分実装
  • 各フォームの入力値に応じて画面の表示を動的に変更する
    • 例) 選択しているラジオボックスにより、フォーム要素の表示、非表示を切り替える
  • 各フォームに細かいバリデーションが必要
    • 例 ) セレクトボックスの組み合わせによっては、同時に選択できない

プロジェクト自体はReduxを使用してstateの管理を行っているため、 onChangeイベントを用いて、フォームの入力を元にUIに関するstateを管理することも出来ます。

しかしフォームの規模が大きいほど、UIに関連するstate管理が煩雑になり、stateを管理するための定型コードの記述量が増えるという問題があります。 また入力の度にレンダリングが走り、パフォーマンスの問題も懸念されます。

上記のような問題点を解消するために、React Hook Formというライブラリを使用してフォーム実装を行うことになりました。 今回は「React Hook Formとは」、「React Hook Formの実際の使い方」について紹介していこうと思います。

Reactを使用したフォーム設計のパターンについて

React Hook Formの説明に入る前に、Reactを使用したフォーム設計パターンについて紹介します。

Reactではフォーム実装において、2つのパターンが存在します。

  • Controlled Component
    • Reactのstateを唯一信頼出来る情報源(single source of truth)とし、フォームをレンダーしているReactコンポーネントが、後続のユーザー入力でフォームに起こるイベントを制御する
  • Uncontrolled Component
    • フォームデータをDOM(ブラウザ)自身が制御する

そもそもHTMLではinputtextareaselectのようなフォーム要素は自身で状態を保持しています。 Uncontrolled Componentによるフォーム構築は、React固有の実装というよりは、ネイティブ(ブラウザ)の実装に近い形になります。

React公式では、Controlled Componentの使用が推奨されています。

React + Reduxの環境下で、Controlled Componentのライブラリとして有名なredux-formでは、Reduxでの状態管理を元にフォーム構築を行います。

しかし、Redux公式では、「経験則に基づくと、Reduxでフォームの状態を管理する必要はないと考えられる」という記載があります。

これらの主張に則れば、「フォームに関する状態をReduxで管理せず、useStateなどのローカルstateを使用して、Controlled Componentパターンでフォームコンポーネントを実装する」という手法がベストプラクティスのように思われます。

しかし、上述したような複雑なフォームのstate管理が必要かつ、大規模なフォームを実装する上で、ローカルstateだけでフォームを実装することは大変です。

そこでReact Hook Formが登場します。

React Hook Formとは ?

公式サイトでは、「シンプルかつ、拡張性のある、使い勝手の良いフォームバリデーションライブラリ」という説明がされています。

Performant, flexible and extensible forms with easy-to-use validation.

React Hook Formは、React16.8.0から導入されたhooksの仕組みを利用したフォームライブラリです。 Uncontrolled Componentsのパターンを採用しており、フォーム毎の参照(ref)をカスタムフックス(useForm)に登録することで、フォームの状態をコントロールします。

useFormが提供するAPIである、registerを使用して、各入力フォームの要素の参照を登録します。

React Hook Formの利点として、公式で紹介されているものとしては以下のような点が挙げられます。

  • state管理などのコードの記述量を減らすことが出来る
  • パッケージが軽量
  • Unontrolled Componentsのパターンを採用しており、レンダリング回数を減らすことが出来る

今回の要件で言えば、特に下記のような問題点に対して、アプローチ出来るため、React Hook Formを選定するに至りました。

  • 10数画面分のフォームを実装するにあたり、各フォームで入力変更を検知するstate、アクションなどを定義していくことが大変
    • 単純なコードの記述量が増える
    • メンテナンス性が低下する
  • テキストエリアなど、長い文章を入力する際にレンダリング回数を減らすことが出来る

それでは実際にコードを書いていきながら紹介していきます。

React Hook Formの基本機能の紹介

簡単なデモを作成して、基本機能を紹介していきます。 動作環境は下記になります。

node            v12.16.2
yarn            v1.22.5
react           v17.0.2
typescript      v4.1.2
react-hook-form v7.1.1

create-react-appにて新規Reactプロジェクトを作成し、 yarn、npmなどのパッケージマネージャを使用して、 React Hook Formをプロジェクトにインストールします。

npx create-react-app react-hook-form-sample --template typescript

yarn add react-hook-form

f:id:takuyawww1101:20210423084510p:plain

画像のような簡単な入力フォームを実装し、React Hook Formについて説明していきます。

SampleForm.tsx

import React from 'react'
import { useForm, SubmitHandler, SubmitErrorHandler } from 'react-hook-form'

type ValuesType = {
  name: string,
  introduction: string,
  department: 'product' | 'sales' | 'marketing' | ''
  programingLanguage: 'golang' | 'ruby' | 'javascript' | ''
}

const SampleForm: React.VFC = () => {
  const { register, watch, handleSubmit, formState } = useForm<valuesType>({
    mode: 'onSubmit',
    reValidateMode: 'onChange',
    defaultValues: {
      name: '',
      introduction: '',
      department: '',
      programingLanguage: ''
    }
  })

  const handleOnSubmit: SubmitHandler<valuesType> = (values) => {
    console.log(values)
  }

  const handleOnError: SubmitErrorHandler<valuesType> = (errors) => {
    console.log(errors)
  }

  return (
    <wrapper>
      <form onSubmit={handleSubmit(handleOnSubmit, handleOnError)} >
        // テキスト項目
        <label htmlFor='name'>Name</label>
        {!!formState.errors.name && 
          <p>{formState.errors.name.message}</p>
        }
        <input
          id='name'
          type="text" 
          isError={!!formState.errors.name} // エラー時にborderの色を変更するためのprops
          {...register('name', {
            required: '* this is required filed'
          })} 
        />

        // テキストエリア項目
        <label htmlFor='introduction'>Introduction</label>
        {!!formState.errors.introduction &&
          <p>{formState.errors.introduction.message}</p>
        }
        <textarea 
          id='introduction'
          isError={!!formState.errors.introduction} 
          {...register('introduction', {
            required: '* this is required filed',
            minLength: {
              value: 10,
              message: '* please enter at least 10 characters'
            }
          })} 
        />

        // セレクトボックス
        <label htmlFor='department'>Department</label>
        {!!formState.errors.department && 
          <p>{formState.errors.department.message}</p>
        }
        <select
          id='department'
          isError={!!formState.errors.department}
          {...register('department', {
            required: '* this is required filed'
          })} 
        >
          <option value='' hidden>please selecting...</option>
          <option value='product'>Product</option>
          <option value='sales'>Sales</option>
          <option value='marketing'>Marketing</option>
        </select>

        // セレクトボックス
        {watch('department') === 'product' &&
          <>
            <label htmlFor='programing-langage'>Programing Language</label>
            <select
              id='programing-language'
              {...register('programingLanguage')} 
            >
              <option value='' hidden>please selecting...</option>
              <option value='golang'>Golang</option>
              <option value='ruby'>Ruby</option>
              <option value='javascript'>Javascript</option>
            </select>
          </>
        }

        // 送信ボタン
        <button type="submit" disabled={!formState.isDirty || formState.isSubmitting}>
           Click
         </button>
      </form>
    </wrapper>
  )
}

useForm

useFormではオプショナルの引数を渡すことで、フォーム全体のバリデーションのタイミングを制御したり、フォームの初回レンダリング時のデフォルト値を設定することができます。

const { register, watch, handleSubmit, formState } = useForm<valuesType>({
    mode: 'onSubmit', // バリデーションが実行されるタイミング
    reValidateMode: 'onChange', // 再度バリデーションを実行するタイミング、onChangeの場合は、入力の度にバリデーションが走る
    defaultValues: { // 初回レンダリング時のフォームのデフォルト値
      name: '',
      introduction: '',
      department: '',
      programingLanguage: ''
    }
  })

modeonChangeを指定することは、 this often comes with a significant impact on performanceと記載があるようにパフォーマンスへの懸念から推奨されていません。

register

inputselect要素をReact Hook Formのバリデーションルールに適用するために、このメソッドを使用します。 第1引数に、登録する参照の名前を設定します。 設定方法によって入力結果をネストしたり、配列で渡すことができます。

register("name") 👉 { name: 'value' }
register("name.firstName") 👉 { name: { firstName: 'value' } }
register("name.firstName.0") 👉 { name: { firstName: [ 'value' ] } }

第2引数にバリデーションのルールをオブジェクトの形式で渡します。 今回だとrequired(必須)minLength(最小文字数)を使用しています。 複雑なバリデーション要件が必要になってくる場合などは、validateオプションを使用すると良さそうです。

f:id:takuyawww1101:20210423085239p:plain

handleSubmit

第1引数に、バリデーション成功時のコールバック関数を、第2引数に、エラー時(バリデーションに引っかかった際)のコールバック関数を登録することができます。

今回は成功時のコールバック関数で、フォームからの入力値を受け取りコンソールに出力しています。渡ってくるデータは下記のようになります。

{
    name: "Takuya Wakazono",
    introduction: "I Like React Hook Form So Much!!",
    department: "product",
    programingLanguage: "javascript"
}

失敗時のコールバック関数ではエラーを内容を受け取ることができます。 渡ってくるデータとしては下記のようになります。(すべて未入力の場合)

{
    name: { type: "required", message: "* this is required field" , ref: "..." },
    introduction: { type: "required", message: "* this is required field" , ref: "..." },
    department: { type: "required", message: "* this is required field" , ref: "..." },
}

formState

フォーム全体に関するstate(状態)をオブジェクト形式で保持しています。 今回であれば、isDirtyerrorsisSubmitting等が該当します。

isDirty: input要素に入力が合った場合はtrueを返す(ユーザーが何も入力していない場合はfalseのまま)
errors: エラーオブジェクトを格納
isSubmitting: 送信中かどうかを判定

watch

入力値を監視し、その値を返します。 主に入力値に応じてフォームのUIを動的に変更する場合などに使用します。

今回はDepertmentを選択する際に、productを選択した場合のみ、プログラミング言語を選択するフォームをレンダリングするようにしています。 f:id:takuyawww1101:20210423085205p:plain

React Hook Formのユースケース

これまでにReact Hook Formの基本的な機能を紹介しましたが、 実際は一つのコンポーネント内にフォームをベタに書くことはほとんど無く、 テキストフォーム、テキストエリア、チェックボックスなどの汎用コンポーネントをimportして、フォームコンポーネントを構築することがほとんどであると思います。

そのような場合に、FormProvideruseFormContextを使用して、registerをpropsとして汎用コンポーネントに渡すことで、フォームを構築することが可能です。

pages/components/common/index.tsx

import React from 'react'
import { UseFormRegisterReturn } from 'react-hook-form'

type PropsType = {
  labelName: string
  register: UseFormRegisterReturn
}

export const TextInput: React.VFC = (props: PropsType) => {
  const { id, labelName, register } = props

  return (
    <>
      <label htmlFor={id}>{labelName}</label>
      <Input id={id} type="text" {...register} />
    </>
  )
}

export const Textarea: React.VFC = (props: PropsType) => {
  const { id, labelName, register } = props

  return (
    <>
      <label htmlFor={id}>{labelName}</label>
      <textarea id={id} {...register} />
    </>
  )
}

export const SelectBox: React.VFC = (props: PropsType) => {
  const { id, labelName, register } = props

  return (
    <>
      <label htmlFor={id}>{labelName}</label>
      <select id={id} {...register}>
        <option>選択肢1</option>
        <option>選択肢2</option>
      </select>
    </>
  )
}

export const SubmitButton: React.VFC = () => {
  return (
    <input type="submit" />
  )
}

SampleForm.tsx

import React from 'react'
// 汎用コンポーネントのimport ===========================
import NestedSampleForm from './'
import { FormProvider, useForm } from 'react-hook-form'

type ValuesType = {
  // ...
}

const SampleForm: React.VFC = () => {
  const methods = useForm<ValuesType>()

  return (
     <FormProvider {...methods} >
        <Form onSubmit={handleSubmit(handleOnSubmit)} >
           <NestedSampleForm />
        </Form>
        <SubmitButton />
     </FormProvider>
  )
}

NestedSampleForm.tsx

import React from 'react'
import { useFormContext } from 'react-hook-form'
// 汎用コンポーネントのimport ===========================
import { TextInput, Textarea, SelectBox } 'pages/components/common'
// =================================================

const NestedSampleForm: React.VFC = () => {
  const { register } = useFormContext<valuesType>()

  return (
     <Wrapper>
        <TextInput labelName='テキスト項目' register={register('text')} />
        <TextareaForm labelName='テキストエリア項目' register={register('textarea')} />
        <SelectBox labelName='セレクトボックス' register={register('selectbox')} />
     </Wrapper>
  )
}

最後に

フォームの要件が複雑になるほど、Reactが推奨しているControlled Componentのパターンでは、state管理が大変になり、辛さを感じていたので、シンプルで使いやすいというのはまさにその通りだなと思いました。

今回紹介した以外にも、たくさん機能があるので、今後React Hook Formを使用していく中で、応用的な使用方法など知見が溜まった際は、また紹介させて頂こうと思います!

スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページを御覧ください。

エンジニア募集ページ