てらブログ

てらブログ

日々の学習の備忘録

React Hook Form × Zod × TypeScriptでフォームを作成する①

背景

フォームを作成することになりました。プロジェクト全体でフォームが沢山登場しそうな雰囲気だったので、よりフォームを簡単に扱うためのライブラリを入れることにしました。
この機会にキャッチアップ・整理したいと思います。
React Hook Formは、フォームのデータのバリデーションやエラー処理を簡単に行うことができ、より高速でパフォーマンスが高いことが特徴らしいです。
またZodを組み合わせることで、より複雑なバリデーションにも対応できそうだったので選択しました。

そもそもライブラリを使用しない場合

useState・useRef・そもそもHookを使用しない、など色々な手段がありました。
useStateを使用すると、下記のようになります。

import { useState } from "react";

export const App = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log({
      email,
      password,
    });
  };

  return (
    <div className="App">
      <h1>ログイン</h1>
      <form onSubmit={handleSubmit}>
        <div>
          <label htmlFor="email">Email</label>
          <input
            id="email"
            name="email"
            value={email}
            onChange={e => {
              setEmail(e.target.value);
            }}
          />
        </div>
        <div>
          <label htmlFor="password">Password</label>
          <input
            id="password"
            name="password"
            value={password}
            onChange={e => {
              setPassword(e.target.value);
            }}
            type="password"
          />
        </div>
        <div>
          <button type="submit">ログイン</button>
        </div>
      </form>
    </div>
  );
}

シンプルなフォームを作成するのであれば、これで良い気もします。。

React Hook Formの使用

次にReact Hook Formのみを使用してフォームを作成していきます。
先ほどのuseStateを使用したフォームを作り替えてみます。

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

type FormValues = {
  email: string;
  password: string;
};

export const App = () => {
  const { register, handleSubmit } = useForm<FormValues>();

  const onSubmit: SubmitHandler<FormValues> = data => {
    console.log(data);
  };

  return (
    <div className="App">
      <h1>ログイン</h1>
      <form onSubmit={onSubmit}>
        <div>
          <label htmlFor="email">Email</label>
          <input
            id="email"
            {...register("email")}
          />
        </div>
        <div>
          <label htmlFor="password">Password</label>
          <input
            id="password"
            {...register("password")}
            type="password"
          />
        </div>
        <button type="submit">ログイン</button>
      </form>
    </div>
  );
}

register内部でrefが使われ、非常にシンプルな記述になりました。
参照:
useForm - register

React Hook Formのバリデーション

シンプルなバリデーション

次にReact Hook Formでバリデーションを作成します。
registerの箇所を{...register("email", { required: "入力が必須の項目です" })}とし、バリデーションを追加。
また、useFormからformState: { errors },を取得し、エラー情報を参照できるようにします。

return (
    <div className="App">
      <h1>ログイン</h1>
      <form onSubmit={onSubmit}>
        <div>
          <label htmlFor="email">Email</label>
          <input
            id="email"
            // 入力必須のバリデーションを追加
            {...register("email", { required: "入力が必須の項目です" })}
          />
          {/* エラーメッセージの表示 */}
          {errors.email?.message && <div>{errors.email.message}</div>}
        </div>
        <div>
          <label htmlFor="password">Password</label>
          <input
            id="password"
            {...register("password")}
            type="password"
          />
        </div>
        <button type="submit">ログイン</button>
      </form>
    </div>
  );

複数のバリデーション

また、1つの入力項目に複数のバリデーションを作成することも可能。
デフォルトだと1つのエラー内容しか取れないので、useFormに{ criteriaMode: "all" }を渡し、
registerにもオブジェクト記法で複数のバリデーションを渡す。

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm<FormValues>({
  criteriaMode: "all",
});

デフォルト値

フォームにデフォルト値を設定することも可能。
useFormにdefaultValuesを渡す。

const { register, handleSubmit } = useForm<FormValues>({
  defaultValues: { email: "test@test.com", password: "pass" },
});

バリデーションの発火タイミング

また、バリデーションのタイミングを変更することもできる。
デフォルトでは1回目はSubmit時で、それ以降はonChangeのタイミングでバリデーションを行う。
例えばuseFormに{ mode: "onChange" }を渡すと、最初からonChageのタイミングでバリデーションが行われるようになる。

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm<FormValues>({
  mode: "onChange",
});

入力状態のチェック

さらに、isDirtyを使用することで、フォームが未入力状態かどうか判定できる。
formState オブジェクトのプロパティにisDirtyを渡す。
submitボタンのdisabledの値として渡すことで、未入力時はボタンを無効にする挙動を実装することができる。

const {
register,
handleSubmit,
formState: { isDirty },
} = useForm<FormValues>();
<button
    type="submit"
    disabled={!isDirty}
>
    ログイン
</button>

詳細な入力チェック

dirtyFieldsを使用すると、それぞれの入力項目に対して未入力状態かを判定できる。

const {
register,
handleSubmit,
formState: { dirtyFields },
} = useForm<FormValues>();
<button
    type="submit"
    disabled={!(dirtyFields.email && dirtyFields.password)}
>
    ログイン
</button>

Zodの利用

ライブラリとリゾルバーをインストール。

% npm install @hookform/resolvers zod

スキーマを利用して、バリデーションの設定を行う。

import { zodResolver } from "@hookform/resolvers/zod";
import * as z from 'zod';

const schema = z.object({
  email: z.string().email().min(1),
  password: z.string().min(1),
});

また、定義した schema を利用するために useForm Hook の引数で zodResolver を利用。

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm({
  resolver: zodResolver(schema),
});

全体のコードとしては下記のようになりました。

/* eslint-disable @typescript-eslint/no-misused-promises */
import { Input } from "@chakra-ui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm, type SubmitHandler } from "react-hook-form";
import * as z from "zod";

const schema = z.object({
  email: z.string().email({ message: "メールアドレスの形式ではありません。" }),
  password: z.string().min(1, { message: "1文字以上入力する必要があります。" }),
});

type FormValues = {
  email: string;
  password: string;
};

const App = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormValues>({ resolver: zodResolver(schema) });

  const onSubmit: SubmitHandler<FormValues> = data => {
    console.log(data);
  };

  return (
    <div className="App">
      <h1>ログイン</h1>
      <form onSubmit={handleSubmit(onSubmit)}>
        <div>
          <label htmlFor="email">Email</label>
          <Input
            id="email"
            {...register("email")}
          />
          <p>{errors.email?.message}</p>
        </div>
        <div>
          <label htmlFor="password">Password</label>
          <Input
            id="password"
            {...register("password")}
            type="password"
          />
          <p>{errors.password?.message}</p>
        </div>
        <button type="submit">ログイン</button>
      </form>
    </div>
  );
};

export default App;

課題・残タスク

今後の課題として、制御系と非制御系でのReact Hook Formの使用方法の違いと、いまいちZodの利点を感じられなかったので、Zodを導入する利点について深堀してみたいと思いました。

参照:
【2022年】 React Hook FormでValidationライブラリはどれにするか?