🐤

弊社で行なったReact Hook Form勉強会の内容

2023/10/06に公開

こんにちは!株式会社 CastingONEの岡本です。

はじめに

弊社のフロントエンドは現在、Nuxt2 から React(Next.js)にリプレイスを進めています。そして、React でのフォーム管理ライブラリにReact Hook Form(以下、RHF)を使用します。

RHF の使い方・特徴を掴むためにcodesandboxを用いながら勉強会を実施しました。今回はその勉強会で行った内容を紹介していきます!

RHF の採用理由

まず初めに RHF を採用した理由と RHF を使用した場合と使用していな場合の動きの違いを codesandbox でみんなで確認しました。


RHF を採用する理由は、レンダリング(再描画)の回数を減らせるからです。 フォームのテキストの入力などの制御にuseStateを使用してしまうと、テキストの状態が更新されるたびにレンダリングしてしまうので、入力内容が多いフォームだと、それに比例してレンダリング回数も増えてしまいます。RHF では非制御コンポーネントを使用して、不要な再レンダリングを防いでくれます。弊社のプロダクトの特徴として、複雑でフィールド数が多いフォームが多数存在するため、RHF の採用が最適だと考えました。

RHF を使わない場合

useStateを使用してフォームの値を管理しています。テキストを入力するたびに Render count が増えていることがわかります。

RHF を使用した場合

テキストなどを入力してもレンダーが起こりませんが、submit すると、しっかり入力したデータを取得することができています。

基本的な使い方

上の章の RHF を使用した場合と使用していない場合の違いを確認した後に、RHF の基本的な使い方についてみんなで確認しました。弊社はMUIを使用しているので、Controller のパターンに重点を置いて勉強しました。


RHF を使用する場合、useFormフックを使用します。このフックはフォームの状態管理とバリデーションを設定できたりします。useFormフックを呼び出すと、フォームの状態とメソッドを提供するオブジェクトが返ってきます。これらのメソッドとプロパティを使用して、フォームの状態を管理し、バリデーションやエラーメッセージの表示、フォームの送信の処理をします。
以下は、主に使用する引数と返り値です。他の値については、公式サイトをご覧ください。

import { useForm } from "react-hook-form";

const {
  register, // フォームフィールドを登録するための関数
  handleSubmit, // フォームの送信を処理するための関数
  reset, // フォームの状態をリセットするための関数
  watch, // フォームフィールドの値を監視するための関数
  control, // Controller コンポーネントで使用するためのオブジェクト
  setValue, // フィールドの値を設定するための関数
  formState, // フォームの状態(dirty, isSubmitted, touched, submitCount, errorなど)を含むオブジェクト
} = useForm({
  defaultValue, // フォームのデフォルト値を設定
  resolver, // yupやzodなどの好みのスキーマバリデーションライブラリとの統合を設定
  values, // リアクティブな値でフォームの値を更新するかどうか設定
  resetOptions, // 新しいフォームの値を更新する際にフォームの状態をリセットするオプションを指定
  criteriaMode, // エラーを表示するモードを設定
  mode, // バリデーションがトリガーされるタイミングを設定
  shouldFocusError, // エラーが発生した場合に、そのエラーのあるフィールドにフォーカスするかどうかを設定
});

register のパターン

register メソッドは RHF で入力要素を登録するために使用されます。これにより、追加の状態管理なしで入力要素をフォームの状態に関連づけることができます。

import { useForm } from "react-hook-form";

const { register, handleSubmit } = useForm({
  defaultValue: {
    name: "",
  },
});

const onSubmit = handleSubmit((data) => {
  // 実際の送信の処理
  console.log(data);
});

return (
  <label>
    名前: <input {...register("name")} />
  </label>
);

controller のパターン

controller メソッドは register の代替手段であり、フォームの入力に対してより多くの制御を行いたい場合に使用されます。それぞれの入力に対して初期値、バリデーションルール、その他のプロパティを手動で定義することができます。外部のライブラリ(MUIChakra UI など)を使用する場合、それらのコンポーネントが ref を直接公開してなければ、Controller を使って RHF と結合することが一般的です。(弊社は UI ライブラリに MUI を採用しているので、MUI のパターンでサンプルを用意しています。)

import { useForm, Controller } from "react-hook-form";

const { handleSubmit, control } = useForm({
  defaultValues: {
    firstName: "",
    lastName: "",
  },
});

return (
  <Controller
    name="firstName"
    control={control} // useFormから返されるcontrolオブジェクトを渡す
    render={({ field, fieldState }) => {
      return (
        <FormControl>
          <TextField
            label="名前"
            inputRef={field.ref}
            value={field.value}
            onBlur={field.onBlur}
            onChange={field.onChange}
          />
        </FormControl>
      );
    }}
  />
);

// render部分はレンダープロップとして機能して、
// 以下のフィールドを持つオブジェクトを受け取る
type field = {
  onChange, // 値が変更されたときに呼び出される関数,
  onBlur, // 入力がフォーカスを失った時に呼び出される関数,
  value, // 制御されるコンポーネントの現在の値
  ref, // Reactのref
  name, // 登録されている入力の名前
},
type fieldState = {
  invalid, // 現在の入力が無効かどうかを示す状態
  isTouched, // 現在の制御される入力が一度でもタッチされたかどうかの状態
  isDirty, // 現在の制御される入力が変更されたかどうかを示す状態
  error, // この特定の入力に関するエラー情報
}

バリデーションについて

useFormControllerの使い方を学んだ後に、RHF にバリデーションを設定する方法について勉強しました。弊社はバリデーションライブラリにyupを採用しているので、yup を例に勉強しました。ここの章を学んだ後にみんなで codesandbox を使ってハンズオンをしました。


RHF でバリデーションを使用するには、Resolver という外部バリデーションライブラリとの統合をサポートするための機能を使用します。定義したバリデーションルールに基づいてフォームのデータを検証し、その結果を RHF に返してくれます。

import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";

const schema = yup.object({
  name: yup.string().required("名前は必須です"),
});

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

エラーの確認

yup などの外部バリデーションを使用する時に、バリデーションに引っかかってもエラーメッセージを表示するような UI にしていなかった場合、エラーが RHF に返されても気づかない場合があります。RHF はバリデーションに引っかかると submit できないので、submit ボタンを押しても変化が起こらず、その原因を検知することが難しいです。その対策として、useFormから帰ってくるhandleSubmitの第二引数でコールバックでエラーが受け取れるので、そちらで確認することをお勧めします。

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

const onSubmit = handleSubmit(
  (data) => {
    // ...
  },
  // ここでバリデーションに引っかかった箇所を確認できる
  (err) => {
    console.log(err);
  }
);

選択した値に応じて、フォームの内容を変更するパターン(watch)

ここの章では少し応用的なフォームの内容を動的に変更したい場合の実装方法について勉強しました。ここも動きを確認した後に、みんなでuseWatchの使い方のハンズオンを実施しました。


ラジオボタンやセレクトボックスで選んだ値によってテキストフォームが追加されるような、特定のフィールドの値の変更をリアルタイムで監視し、それに応じて何らかの処理を行いたいことがあると思います。その時は、useFormから帰ってくるwatchを使用できます。以下、使用例です。

import { useForm, useWatch, Controller } from "react-hook-form";

const JOB_TYPE_OPTIONS = [
  { value: "student", label: "学生" },
  { value: "officeWorker", label: "会社員" },
  { value: "other", label: "その他" },
];

export type FormType = {
  jobType: "student" | "officeWorker" | "other";
  otherJobName: string;
};

const { control, handleSubmit, watch } = useForm({
  defaultValues: {
    otherJobName: "",
    jobType: "student",
  },
});

// 引数にwatchしたいフォームのアイテムを指定する
const selectedJobType = watch("jobType");

return (
  <Stack>
    <Controller
      name="jobType"
      control={control}
      render={({ field, fieldState }) => {
        const errorMessage = fieldState.error?.message;
        return (
          <FormControl variant="standard" error={!!errorMessage}>
            <FormLabel>職業</FormLabel>
            <RadioGroup
              ref={field.ref}
              row
              value={field.value}
              onChange={(_, value) => {
                field.onChange(value);
              }}
              onBlur={field.onBlur}
            >
              {JOB_TYPE_OPTIONS.map((option) => (
                <FormControlLabel
                  key={option.value}
                  value={option.value}
                  control={<Radio />}
                  label={option.label}
                />
              ))}
            </RadioGroup>
            <FormHelperText>{errorMessage}</FormHelperText>
          </FormControl>
        );
      }}
    />
    {selectedJobType === "other" && (
      <Controller
        name="otherJobName"
        control={control}
        render={({ field, fieldState }) => {
          const errorMessage = fieldState.error?.message;
          return (
            <TextField
              inputRef={field.ref}
              value={field.value}
              label="その他の職業"
              error={!!errorMessage}
              helperText={errorMessage}
              onChange={field.onChange}
              onBlur={field.onBlur}
            />
          );
        }}
      />
    )}
  </Stack>
);

watch監視対象の値が変更されるたびに再レンダリングが走ってしまいます。 フォームのような他の要素を多く含むコンポーネントでは、RHF を使っているのに、繰り返しレンダリングが発生してしまいます。

そのレンダリングを最小限に抑える方法として、useWatchを使用する方法があります。useWatchwatchと基本的な動きは同じですが、カスタムフックとして使用でき特定のコンポーネント内でのみレンダリングが発生します。

親コンポーネントでuseFormを使い、control を受け取り、それを子コンポーネントに渡し、子コンポーネントでuseWatchに、親から受け取った controlを渡し、nameに監視したいフィールド名を指定することで、子コンポーネントに watch による再レンダリングを閉じ込めることができます。

RhfOtherJobName.tsx
import type { FormType } from './Parent'
import { useWatch, Controller } from 'react-hook-form'

type Props = {
  control: Control<FormType>
}

export const RhfOtherJobName: FC<Props> = ({ control }) => {
  const selectedJobType = useWatch({ name: 'jobType', control })

  if (selectedJobType !== 'other') {
    return <></>
  }

  return (
    <Controller
      name="otherJobName"
      control={control}
      render={({ field }) => {
        // ....
      }}
    />
  )
}
Parent.tsx
import { useForm } from 'react-hook-form';
import { RhfOtherJobName } from './RhfOtherJobName'

const JOB_TYPE_OPTIONS = [
  { value: "student", label: "学生" },
  { value: "officeWorker", label: "会社員" },
  { value: "other", label: "その他" },
];

export type FormType = {
  jobType: 'student' | 'officeWorker' | 'other';
  otherJobName: string;
};

export const ParentForm: FC = () => {
  const { handleSubmit, control } = useForm<FormType>({
    defaultValues: {
      otherJobName: '',
      jobType: 'student'
    }
  })

  return (
    <form onSubmit={handleSubmit}>
      <Controller
        name="jobType"
        control={control}
        render={({ field, fieldState }) => {
          // ...
        }}
      />
      {/* レンダリングをこのコンポーネント内だけに閉じ込めることができる */}
      <RhfOtherJobName control={control} />
    </form>
  )
}

監視対象部分をコンポーネント化し、 そのコンポーネントに watch を渡した場合

watchの場合だと、監視対象部分をコンポーネント化しても、親コンポーネント部分でレンダリングが走っているのがわかります。

監視対象部分をコンポーネント化し、useWatch を使用した場合

useWatch の場合だと、レンダリングされる箇所がコンポーネント内部だけになっているのが分かります。

フォームを増やしたり減らしたりするパターン

最後にフォームの項目を動的に増やしたり減らしたりするパターンについて勉強しました。こちらもuseFieldArrayの使い方を学習したのち、ハンズオンを実施しました。


フォームのよくあるのパターンの一つに TODO リストのような動的にフォーム項目を増やしたり減らしたりことができるパターンがあると思います。その場合は、useFieldArrayを使うことでフォーム項目を動的に増やすことができます。以下、使用例です。

import { useForm, useFieldArray } from "react-hook-form";

type FormData = {
  items: { name: string }[];
};

export const UseFieldArraySample = () => {
  const { control, handleSubmit } = useForm<FormData>();
  const {
    fields, // 指定されたアイテムの配列
    append, // 配列の最後にアイテムを追加できる関数
    remove, // 指定された位置のアイテムを削除できる関数
  } = useFieldArray({
    control,
    name: "items", // 動的に増やすフィールドパスを指定する
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {fields.map((field, index) => (
        <Box>
          <Controller
            name={`items.${index}.name`}
            control={control}
            render={({ field }) => {
              return (
                <TextField
                  inputRef={field.ref}
                  label="名前"
                  value={field.value}
                  onBlur={field.onBlur}
                  onChange={field.onChange}
                />
              );
            }}
          />
          <Button type="button" onClick={() => remove(index)}>
            削除
          </Button>
        </Box>
      ))}
      <Button type="button" onClick={() => append({ name: "" })}>
        追加
      </Button>
    </form>
  );
};

useFieldArraywatchと同様に、追加や削除を行うとレンダリングが走ります。なので、useWatchと同様に動的にフォームの項目を増減させたい部分をコンポーネント化し、そのコンポーネントにcontrolとフォームのnameを渡して、コンポーネント内でuseFieldArrayを呼び出すことによって、レンダリングをコンポーネント内に閉じ込めることができます。

終わりに

以上が、弊社で行なった React Hook Form についてでした。弊社では、React 勉強会以外も様々な勉強会が開催されているので、気になった方は社員でもフリーランスでも、フルタイムでも短時間の副業でも大歓迎なので、気軽にご連絡ください!

https://www.wantedly.com/projects/1130967

https://www.wantedly.com/projects/768663

Discussion