i-Vinci TechBlog
株式会社i-Vinciの技術ブログ

react-hook-formで入力フォームを作る

最近、開発やコードレビューする中で、自身の知見のなさを実感しているakiyoshiです。

最近まで入力フォームを作るときに、項目の数だけ増えていくstateが当たり前だと思っていましたが、
フォーム以外のstateも加味すると、徐々にメンテナンスできる気がしなくなってきました。
※それは設計が悪いとは言われるとそうかもしれませんが・・・。
そんな中で調べてみると便利そうなreact-hook-formなるものがあったため、試してみました。
例のごとく公式ドキュメントが一番詳しいので、実際に使うときはそちらを参照してください。

npm create vite@latest vite-form-practice  -- --template react-ts
cd vite-form-practice/
npm i react-hook-form

実際に書くとこうなる

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

type InputForm = {
  name: string;
  email: string;
  gender: (typeof genderValue)[number]["value"];
};

const genderValue = [
  { value: 0, text: "FEMALE" },
  { value: 1, text: "MALE" },
  { value: 2, text: "OTHERS" },
] as const;

export const Form = () => {
  // 使いたいAPIをインポート
  const {
    register,
    handleSubmit,
    reset,
    formState: { isDirty, errors },
  } = useForm<InputForm>({
    // 初期値を設定
    defaultValues: {
      name: "vinci taro",
      email: "init@example.com",
      gender: 1,
    },
  });

  // 有効なデータでsubmit押下時に実行される
  const submitValidData = (data: InputForm) => {
    console.log(data);
  };

  // 無効なデータでsubmit押下時に実行される(省略可)
  const alertInvalidData = (data: FieldErrors<InputForm>) => {
    console.error(data);
  };

  // 動作確認でログを出したかったため、reset関数をラップ
  const handleReset = () => {
    console.log("reset");
    reset();
  };

  return (
    <>
      <h1>input form</h1>
      {/* submitボタン押下で実行されるラップ関数 */}
      <form onSubmit={handleSubmit(submitValidData, alertInvalidData)}>
        <div>
          <label>名前:</label>
          {/* inputされた値をnameというキーで登録し、参照できるようにする */}
          <input {...register("name")} />
        </div>
        <div>
          <label>Email:</label>
          <input
            {...register("email", {
              // バリデーションで必須チェックとパターンマッチングをしている
              required: { value: true, message: "メッセージは必須です" },
              pattern: {
                value:
                  /^[A-Za-z0-9]{1}[A-Za-z0-9_.-]*@[A-Za-z0-9_.-]{1,}.[A-Za-z0-9]{1,}$/,
                message: "メールの形式が不正です",
              },
            })}
          />
          {/* 上記バリデーションで引っかかると、errosオブジェクトに値が入るため、それを利用している */}
          {errors?.email && (
            <p style={{ color: "red" }}>{errors.email.message}</p>
          )}
        </div>
        <div>
          <label>性別:</label>
          <select {...register("gender", { min: 0, max: 2 })}>
            {genderValue.map((item) => (
              <option value={item.value} key={item.value}>
                {item.text}
              </option>
            ))}
          </select>
        </div>
        {/* フォーム内で値の変更がない場合は、buttonを無効化している */}
        <button disabled={!isDirty} type="submit">
          submit
        </button>
        {/* フォーム内の値を初期値にリセットする */}
        <button onClick={handleReset} type="button">reset</button>
      </form>
    </>
  );
};

inputForm初期表示

便利と感じたポイント

いろいろと要素が多いので、主要なところだけ書きます。(できることが多すぎる)

  • defaultValues
    • オブジェクトを渡すことでフォームの初期値を設定できます。
  • register
    • 入力された値を参照するために利用します。
      第1引数ではキーとなる項目名、第2引数でバリデーションのルールとエラーメッセージを設定できます(省略可)
  • handleSubmit
    • 引数は2つとり、有効なデータの処理と無効なデータの処理でパターン分けができます。
    • 有効なデータの場合にはフォームに入力した値({name: inputタグのname, email: inputタグのemail, gender: selectタグのgender};)を引数で指定した関数(=ラップされた関数)に渡すことができます。
  • errors
    • バリデーションエラーのオブジェクトが入ります(handleSubmitの第2引数に渡されるもの)
      オブジェクトの実態は、スクリーンショットのコンソールを参照
      無効なデータ
  • reset
    • フォーム内の入力値を1発で初期値に戻すことができます。ここでいう初期値はdefaultValuesで設定した値です。
  • isDirty
    • 入力値に変更があったかどうかをbool値で持ちます

他にもバリデーションのタイミングをonChangeonSubmitの制御ができたり、
フォームの値を個別取得getValues・設定setValueできたりと、ひととおりやりたいことができるようになっています。

追加でバリデーションは他のライブラリも利用できる

useFormにresolversを渡すことで使えるようになります。
Zodとしてはやることは変わらないないので触れませんが、組み込むとこんな感じになります。
個人的には、UIコード内にバリデーションやメッセージまで書きたくないので、別ファイルに分離できるところが良いです。
※小規模なフォームならそのままでいい気もするので、さじ加減が難しいです。

// zod利用のために定義済みのschemaをimport
import { schema } from "./validate";
import { zodResolver } from "@hookform/resolvers/zod";

// ---中略---

 const {
    register,
    handleSubmit,
    reset,
    formState: { isDirty, errors },
  } = useForm<InputForm>({
    // 初期値を設定
    defaultValues: {
      name: "vinci taro",
      email: "init@example.com",
      gender: 1,
    },
    resolver: zodResolver(schema), // zod利用のために追加
  });

// ---中略---
<div>
  <label>Email:</label>
  {/* react-hook-formのバリデーションを削除 */}
  <input {...register("email")} />
  {errors?.email && (
    <p style={{ color: "red" }}>{errors.email.message}</p>
  )}
</div>

zod無効なデータ

UIコンポーネントライブラリも入れてみる

今回はMUI(Material UI)を追加しましたが、コードの印象がかなり変わりますね。
フロントのコード自体読み慣れていないときには、辛かったです。
個人的には、どこがMUIで、どこがreact-hook-formかを意識して読めば、何とかなりました。

import { Controller, FieldErrors, useForm } from "react-hook-form";
import { schema } from "./validate";
import { zodResolver } from "@hookform/resolvers/zod";
import {
  Box,
  Button,
  FormControl,
  InputLabel,
  MenuItem,
  Select,
  TextField,
} from "@mui/material";

type InputForm = {
  name: string;
  email: string;
  gender: (typeof genderValue)[number]["value"];
};

const genderValue = [
  { value: 0, text: "FEMALE" },
  { value: 1, text: "MALE" },
  { value: 2, text: "OTHERS" },
] as const;

export const Form = () => {
  const {
    handleSubmit,
    reset,
    control,
    formState: { isDirty },
  } = useForm<InputForm>({
    // 初期値を設定
    defaultValues: {
      name: "vinci taro",
      email: "init@example.com",
      gender: 1,
    },
    resolver: zodResolver(schema),
  });

  // 有効なデータでsubmit押下時に実行される
  const submitValidData = (data: InputForm) => {
    console.log(data);
  };

  const handleReset = () => {
    console.log("reset");
    reset();
  };

  // 無効なデータでsubmit押下時に実行される(省略可)
  const alertInvalidData = (data: FieldErrors<InputForm>) => {
    console.error(data);
  };

  return (
    <>
      <h1>input form</h1>
      <Box
        component="form"
        onSubmit={handleSubmit(submitValidData, alertInvalidData)}
      >
        <Controller
          name="name"
          control={control}
          render={({ field, formState: { errors } }) => (
            // https://mui.com/material-ui/react-text-field/
            <TextField
              {...field}
              label="名前"
              error={!!errors.name}
              fullWidth
            />
          )}
        />
        <Controller
          name="email"
          control={control}
          render={({ field, formState: { errors } }) => (
            <TextField
              {...field}
              label="Email"
              error={!!errors.email}
              fullWidth
              helperText={errors.email?.message as string}
            />
          )}
        />
        <Box>
          <Controller
            name="gender"
            control={control}
            render={({ field, formState: { errors } }) => (
              // https://mui.com/material-ui/react-select/
              <FormControl fullWidth error={!!errors.gender}>
                <InputLabel id="gender">性別</InputLabel>
                <Select {...field}>
                  {genderValue.map((item) => {
                    return (
                      <MenuItem value={item.value} key={item.value}>
                        {item.text}
                      </MenuItem>
                    );
                  })}
                </Select>
              </FormControl>
            )}
          />
        </Box>
        {/* フォーム内で値の変更がない場合は、buttonを無効化している */}
        <Button variant="contained" disabled={!isDirty} type="submit">
          submit
        </Button>
        {/* フォーム内の値を初期値にリセットする */}
        <Button variant="outlined" onClick={handleReset} type="button">
          reset
        </Button>
      </Box>
    </>
  );
};
  • 初期表示
    MuiForm init
  • エラー発生
    MuiForm errors

まとめ

formに関連するstateが無くなるだけでも、すっきりして助かりますね。
それだけではなく、他のライブラリとの合わせ技で使えるようになっている点もありがたいです。

ただし、1つの項目で複数の値を持つ場合はuseFieldArray、UIライブラリを使いたい場合にはuseControllerを使うなど、使いこなそうとすると一時的とはいえ、少しコストが高くなりそうな印象です。
また、すでにuseStateでフォームの値を管理しているところに、便利だからとreact-hook-formを導入する場合はチーム内で開発方針のすり合わせを行いましょう。
下手に共存状態を作り出してしまうと、stateの管理ができなくなり、バグを生む可能性があります。

参考

導入方法からの説明もあるので、公式ドキュメントから入るとよいです。