T.Ichikawaをフォローする

【React】React Hook FormのuseForm + useController と Zodで汎用的なコンポーネントを作成してみた

フロントエンド

はじめに

前回はReact Hook FormのuseFormとZodを組み合わせて入力フォームを作成しました。
各ファイルに記述する分にはuseFormのregisterを使用すれば問題無く実装できます。
ただ、入力フォームは色々なところで使用することが多いと思いますので、できたら共通化したいですよね。
そこで今回はReact Hook FormのuseControllerも組み合わせて、テキストボックスやセレクトボックスの入力欄を共通化してみたいと思います。

registerやZodについては以前紹介した記事をご参照ください!

useControllerとは?

再利用可能な入力フォームを作成するためのカスタムフック
基本的にはuseFormで定義した項目名やcontrolを受け取り、useControllerのonChangeメソッドを使用してuseFormの値を更新・バリデーションの実行を行います。
useFormのControllerと同じような感覚ですが、大きく違う点は「nameやcontrolの指定方法」です。

  • Controller:Controllerタグの中でnameやcontrolを指定
  • useController:UIとは別にuseControllerの引数に指定

簡単に言えば、UIと制御を分けて記載することが可能です。

// Controllerの場合は要素の中に指定
<Controller
  control={ control }
  name={ name }
  render={ 省略 }
/>

// useControllerの場合は引数に指定
const { 省略 } = useController<T>({ name, control });

作ってみよう

以下の条件で入力フォームを作成してみます。

項目名要素入力制限
氏名テキストボックス必須
100文字以内
フリガナテキストボックス必須
カタカナ
100文字以内
性別セレクトボックス必須
メールアドレステキストボックス必須
254文字以内
メールアドレス形式
郵便番号テキストボックス必須
8文字以内
{半角数字3桁}-{半角数字4桁}
住所テキストボックス必須
100文字以内
建物名テキストボックス100文字以内
職業セレクトボックス必須
交通手段セレクトボックス必須
趣味セレクトボックス必須

完成系

テキストエリアのコンポーネント
import { useController, type UseControllerProps, type FieldValues } from 'react-hook-form';

type Props<T extends FieldValues> = UseControllerProps<T>;

const InputForm = <T extends FieldValues>(props: Props<T>) => {
  const { name, control } = props;
  const {
    field: { ref, onChange },
    fieldState: { error },
  } = useController<T>({ name, control });

  return (
    <div>
      <input type="text" ref={ref} onChange={(e) => onChange(e.target.value)} />
      {error?.message && <span style={{ color: 'red' }}>{error.message}</span>}
    </div>
  );
};

export default InputForm;
セレクトボックスのコンポーネント
import { useController, type FieldValues, type UseControllerProps } from 'react-hook-form';

type SelectBoxOption = {
  label: string;
  value: string;
};

type Props<T extends FieldValues> = {
  options: SelectBoxOption[];
} & UseControllerProps<T>;

const SelectBoxForm = <T extends FieldValues>(props: Props<T>) => {
  const { name, control, options } = props;
  const {
    field: { ref, onChange },
    fieldState: { error },
  } = useController<T>({ name, control });

  return (
    <div>
      <select ref={ref} onChange={onChange}>
        <option key="0" value="" />
        {options.map((data) => {
          return (
            <option key={data.value} value={data.value}>
              {data.label}
            </option>
          );
        })}
      </select>
      {error?.message && <span style={{ color: 'red' }}>{error.message}</span>}
    </div>
  );
};

export default SelectBoxForm;
親ファイル
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import InputForm from '$/components/atom/InputForm';
import SelectBoxForm from '$/components/atom/SelectBoxForm';

const SexOptions = [{ value: '1', label: '男性' }, { value: '2', label: '女性' }];

const OccupationOptions = [{ value: '1', label: '学生' }, { value: '2', label: '社会人' }];

const TransportOptions = [{ value: '1', label: '徒歩' }, { value: '2', label: '電車' }];

const HobbyOptions = [{ value: '1', label: '運動' }, { value: '2', label: 'ゲーム' }];

// Zodでのスキーマ設定
const Schema = z.object({
  name: z.string().nonempty({ message: '氏名を入力してください。' }).max(100),
  kana: z
    .string()
    .nonempty({ message: 'フリガナを入力してください。' })
    .regex(/^[ァ-ンヴー]+$/, {
      message: 'カタカナで入力してください。',
    })
    .max(100),
  sex: z.string().nonempty({ message: '性別を選択してください。' }),
  mail: z
    .string()
    .nonempty({ message: 'メールアドレスを入力してください。' })
    .max(254)
    .email({ message: 'メールアドレス形式で入力してください。' }),
  code: z
    .string()
    .nonempty({ message: '郵便番号を入力してください。' })
    .regex(/^[\d]{3}-[\d]{4}$/, {
      message: '郵便番号形式で入力してください。',
    })
    .max(8),
  address: z.string().nonempty({ message: '住所を入力してください。' }).max(100),
  building: z.string().max(100),
  occupation: z.string().nonempty({ message: '職種を選択してください。' }),
  transport: z.string().nonempty({ message: '交通手段を選択してください。' }),
  hobby: z.string().nonempty({ message: '趣味を選択してください。' }),
});

const UseControllerNormal = () => {
  const { control, handleSubmit } = useForm({
    defaultValues: {
      name: '',
      kana: '',
      sex: '',
      mail: '',
      code: '',
      address: '',
      building: '',
      occupation: '',
      transport: '',
      hobby: '',
    },
    mode: 'onChange',
    resolver: zodResolver(Schema),
  });

  const onSubmit = handleSubmit(() => {
    alert('成功');
  });

  return (
    <div>
      <div>
        <label>氏名</label>
        <InputForm control={control} name="name" />
      </div>
      <div>
        <label>フリガナ</label>
        <InputForm control={control} name="kana" />
      </div>
      <div>
        <label>性別</label>
        <SelectBoxForm control={control} name="sex" options={SexOptions} />
      </div>
      <div>
        <label>メールアドレス</label>
        <InputForm control={control} name="mail" />
      </div>
      <div>
        <label>郵便番号</label>
        <InputForm control={control} name="code" />
      </div>
      <div>
        <label>住所</label>
        <InputForm control={control} name="address" />
      </div>
      <div>
        <label>建物名</label>
        <InputForm control={control} name="building" />
      </div>
      <div>
        <label>職業</label>
        <SelectBoxForm control={control} name="occupation" options={OccupationOptions} />
      </div>
      <div>
        <label>交通手段</label>
        <SelectBoxForm control={control} name="transport" options={TransportOptions} />
      </div>
      <div>
        <label>趣味</label>
        <SelectBoxForm control={control} name="hobby" options={HobbyOptions} />
      </div>

      <button onClick={onSubmit}>確定</button>
    </div>
  );
};

export default UseControllerNormal;

解説

今回はuseControllerの機能を中心に解説していきます。
useFormと組み合わせているので、使用していないプロパティ等が出てきますが割愛させていただきます。
zodやuseFormについては前回の記事で解説していますので、そちらをご参照ください。

Props

親コンポーネントからuseFormの型を参照するようにするためUseControllerPropsを使用します。
共通化ということで、親から型を定義するために抽象的な型引数<T>を与えています。

import type { UseControllerProps, FieldValues } from 'react-hook-form';

export type Props<T extends FieldValues> = UseControllerProps<T>;

useControllerのメソッドについて

今回はuseForm + Zodと組み合わせているので、基本的なメソッドのみを使用しています。
useController単体で実装する際には、defaultValue(初期値)やrules(バリデーション)などを設定します。
useControllerの引数にはPropsで受け取ったnameとcontrolを指定します。

  const {
    field: { ref, onChange },
    fieldState: { error },
  } = useController<T>({ name, control });
  • onChange:useFormで定義した値を更新する
  • error:Zodで定義したバリデーションを実行した結果

最後に

冒頭でもお話ししましたが、やはり共通化には適してると思いました。
親から項目名やバリデーションルールを渡してあげるだけで、onChangeの制御やエラー表示などを行ってくれます。
そのため、レイアウトやonChange時の処理などを1ファイルで一元管理することができます。
レイアウトや動作に統一感を持たせることができるので個人的には好印象でした。
最低限、nameとcontrolを渡すだけで作れるというのも初心者にもわかりやすいと思います。
もしテキストフィールドなどを共通化したいという課題があれば検討してみてください。

公式サイトのリンクも載せておきますので、ぜひぜひご参照ください!
https://react-hook-form.com/docs/usecontroller