【React】React Hook Formでフォームバリデーションを実装する方法

JavaScript

こんにちは、かつコーチです。

前回はReact Query(TanStack Query)を使ったデータ取得の効率化について解説しました。

データを「取得して表示する」側の話が一段落したので、今回は反対に「ユーザーからデータを受け取る」側、フォーム入力とバリデーションを扱います。

Reactでフォームを作るとき、useStateで愚直に書いていくと入力欄が増えるたびにコードが煩雑になっていきます。

そこで登場するのがReact Hook Formです。

React Hook Formとは

なぜ素のuseStateでフォームを作ると大変なのか

まずはuseStateだけでフォームを作った場合のイメージを見てみましょう。

import { useState } from "react";

const SignupForm = () => {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [nameError, setNameError] = useState("");
  const [emailError, setEmailError] = useState("");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    setNameError(name === "" ? "名前は必須です" : "");
    setEmailError(!email.includes("@") ? "メールアドレスの形式が不正です" : "");
    // ここからさらに送信処理が続く…
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      {nameError && <p>{nameError}</p>}
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      {emailError && <p>{emailError}</p>}
      <button type="submit">送信</button>
    </form>
  );
};

入力項目が2つだけでもこの分量です。

項目が10個、20個と増えたフォームを想像すると、状態変数とバリデーション処理の管理だけでコードが膨大になることが分かるかと思います。

また、onChangeのたびに再レンダリングが発生するため、大きなフォームではパフォーマンス面でも不利になりがちです。

React Hook Formが解決すること

React Hook Formは、入力値の管理を非制御コンポーネント(uncontrolled component)の仕組みに寄せることで、再レンダリングを最小限に抑えながらフォームを扱えるライブラリです。

状態変数を自分で1つずつ用意する必要がなく、バリデーションルールも宣言的に指定できるため、コード量が大幅に減ります。

基本の使い方

useFormとregisterで入力欄を作る

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

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

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

  const onSubmit = (data: FormValues) => {
    console.log("送信データ:", data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name", { required: "名前は必須です" })} />
      {errors.name && <p>{errors.name.message}</p>}

      <input
        {...register("email", {
          required: "メールアドレスは必須です",
          pattern: {
            value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
            message: "メールアドレスの形式が不正です",
          },
        })}
      />
      {errors.email && <p>{errors.email.message}</p>}

      <button type="submit">送信</button>
    </form>
  );
};

export default SignupForm;

register("name", { required: ... })のように書くだけで、入力欄の値の追跡とバリデーションルールの適用が同時に行われます。

handleSubmit(onSubmit)は、バリデーションを通過した場合のみonSubmitを実行してくれるため、送信処理の中で毎回バリデーション結果をチェックする必要がありません。

バリデーションルールの指定

registerの第2引数には、requiredのほかにもさまざまなルールを指定できます。

ルール役割
required必須入力
minLength / maxLength文字数の下限・上限
min / max数値の下限・上限
pattern正規表現によるフォーマットチェック
validate任意のロジックによるカスタムバリデーション

validateを使うと、「パスワードと確認用パスワードが一致しているか」のような、単純なルールでは表現できないチェックも書けます。

<input
  {...register("passwordConfirm", {
    validate: (value, formValues) =>
      value === formValues.password || "パスワードが一致しません",
  })}
/>

よくあるつまずきポイント:valueとonChangeを両方書いてしまう

制御コンポーネントの癖が抜けない

React Hook Formを使い始めた頃、私はuseStateでフォームを作っていた頃の癖が抜けず、registervalueonChangeを両方書いてしまい、動作がおかしくなった経験があります。

❌ Before:registerとvalue・onChangeを両方指定する

const [name, setName] = useState("");

<input
  {...register("name")}
  value={name} // registerが管理している値と競合する
  onChange={(e) => setName(e.target.value)} // registerのonChangeを上書きしてしまう
/>

registerは内部で独自にrefonChangenameなどを設定しています。

そこに自前のvalueonChangeを後から書いてしまうと、React Hook Form側の値の追跡が正しく機能しなくなり、バリデーションが反応しない・送信データが空になるといった不具合が起きます。

✅ After:registerだけに入力欄の管理を任せる

<input {...register("name", { required: "名前は必須です" })} />

React Hook Formを使う場合、入力欄の値の管理は基本的に全てregisterに任せ、useStateで二重管理しないのが鉄則です。

初期値を入れたい場合は、useFormdefaultValuesオプションを使いましょう。

const { register } = useForm<FormValues>({
  defaultValues: { name: "", email: "" },
});

応用・一歩先の使い方

Zodと組み合わせたスキーマバリデーション

registerにルールを1つずつ書いていく方法は手軽ですが、項目数が増えると見通しが悪くなります。

そこで、Zodのようなスキーマバリデーションライブラリと@hookform/resolversを組み合わせる方法がよく使われます。

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const schema = z.object({
  name: z.string().min(1, "名前は必須です"),
  email: z.string().email("メールアドレスの形式が不正です"),
});

type FormValues = z.infer<typeof schema>;

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

  const onSubmit = (data: FormValues) => {
    console.log("送信データ:", data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} />
      {errors.name && <p>{errors.name.message}</p>}
      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}
      <button type="submit">送信</button>
    </form>
  );
};

バリデーションルールをスキーマとして一箇所にまとめることで、フォームの構造とバリデーションロジックが分離され、見通しがよくなります。

エラーメッセージの表示とUX

エラーメッセージは、入力欄のすぐ下に表示するのが基本ですが、modeオプションでバリデーションのタイミングも調整できます。

useForm<FormValues>({
  mode: "onBlur", // 入力欄からフォーカスが外れたタイミングで検証する
});

デフォルトのonSubmit(送信時にまとめて検証)に対し、onBluronChangeを指定すると、ユーザーが入力している最中にリアルタイムでフィードバックを返せます。

ただしonChangeはキー入力のたびに検証が走るため、入力途中で赤いエラー表示が何度も出てユーザーを急かしてしまうこともあるので、フォームの性質に合わせて選びましょう。

まとめ

この記事のポイント

  • useStateでフォームを作ると、項目数に比例してコードが煩雑になる
  • React Hook Formはregisterで入力欄の値の管理とバリデーションを同時に行える
  • registerと自前のvalueonChangeを併用すると値の追跡が壊れるため避ける
  • Zod等のスキーマバリデーションと組み合わせると、ルールを一箇所にまとめられる
  • modeオプションでバリデーションのタイミング(送信時/フォーカスアウト時など)を調整できる

次に読むべき記事

→ 次の記事:Reactアプリに認証機能を実装するパターン

タイトルとURLをコピーしました