React でフォーム管理入門:React Hook Form で楽にバリデーションまで作る

概要

React でフォームを作ると、useState を入力欄の数だけ並べ、onChange を書き、バリデーションを手で組み、エラー表示の状態も別途管理し……と、あっという間にコンポーネントが肥大化します。React Hook Form(以下 RHF)は、この定型作業を大幅に減らし、しかも「入力のたびに全体が再レンダリングされる」問題も抑えてくれるライブラリです。

この記事では、素の useState で作ったフォームを出発点に、RHF へ置き換え、最終的に Zod でスキーマバリデーションまで通すところをハンズオンで進めます。

環境

  • Node.js 20 系
  • React 18 / TypeScript 5
  • Vite(react-ts テンプレート)
  • react-hook-form 7 系 / zod 3 系 / @hookform/resolvers 3 系
npm install react-hook-form zod @hookform/resolvers

素の useState だと何がつらいか

import { useState } from "react";

export function SignUpFormNaive() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [errors, setErrors] = useState<{ email?: string; password?: string }>({});

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const next: typeof errors = {};
    if (!email.includes("@")) next.email = "メールアドレスの形式が不正です";
    if (password.length < 8) next.password = "8文字以上で入力してください";
    setErrors(next);
    if (Object.keys(next).length > 0) return;
    console.log({ email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      {errors.email && <p>{errors.email}</p>}
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      {errors.password && <p>{errors.password}</p>}
      <button type="submit">登録</button>
    </form>
  );
}

問題点は主に3つです。

  1. 入力欄が増えるほど useState と onChange が増殖する
  2. バリデーションのルールがロジックに埋もれ、再利用しづらい
  3. 1文字打つたびに setState が走り、フォーム全体が再レンダリングされる

RHF へ置き換える

RHF の中心は useForm フックと、それが返す register です。register を input に展開するだけで、値の追跡・onChange・onBlur の配線が済みます。

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

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

export function SignUpForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>();

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

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

      <input
        type="password"
        {...register("password", {
          required: "パスワードは必須です",
          minLength: { value: 8, message: "8文字以上で入力してください" },
        })}
      />
      {errors.password && <p>{errors.password.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        登録
      </button>
    </form>
  );
}

ポイントを整理します。

  • useForm<FormValues>() に型を渡すと、register のフィールド名や onSubmit の引数が型チェックされる
  • handleSubmit はバリデーションを通過したときだけ onSubmit を呼ぶ
  • formState.errors にフィールド名ごとのエラーが入る
  • isSubmitting を使えば二重送信防止のボタン無効化が簡単に書ける

useState を1つも書いていない点に注目してください。値の保持は RHF が内部で持ちます。

なぜ再レンダリングが減るのか

RHF は既定で 非制御コンポーネント(uncontrolled) として動きます。register は内部で ref を使って DOM から値を読むため、1文字打つたびに React の state が更新されるわけではありません。結果として、入力中にコンポーネントツリーが再描画され続けることを避けられます。

入力中の値をリアルタイムに画面へ反映したいときは watch を使います。ただし watch した箇所は再レンダリングの対象になるので、必要な範囲に限定するのがコツです。

const email = watch("email");

Zod でスキーマバリデーションにする

バリデーションを1か所のスキーマに集約し、そこから型も導出できるのが Zod との組み合わせです。

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

const schema = z
  .object({
    email: z.string().email("メールアドレスの形式が不正です"),
    password: z.string().min(8, "8文字以上で入力してください"),
    passwordConfirm: z.string(),
  })
  .refine((v) => v.password === v.passwordConfirm, {
    message: "パスワードが一致しません",
    path: ["passwordConfirm"],
  });

type FormValues = z.infer<typeof schema>;

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

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

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="password" {...register("password")} />
      {errors.password && <p>{errors.password.message}</p>}

      <input type="password" {...register("passwordConfirm")} />
      {errors.passwordConfirm && <p>{errors.passwordConfirm.message}</p>}

      <button type="submit">登録</button>
    </form>
  );
}

嬉しい点は2つです。

  • z.infer<typeof schema> でスキーマから型を自動生成できるので、型定義とバリデーションが二重管理にならない
  • .refine でフィールドをまたいだ検証(パスワード一致など)を宣言的に書ける

UI ライブラリなど「制御された入力」との連携

value/onChange を渡す前提のコンポーネント(Material UI や React Select など)は register をそのまま展開できません。この場合は Controller を使います。

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

const options = [
  { value: "jp", label: "日本" },
  { value: "us", label: "アメリカ" },
];

function CountryField() {
  const { control, handleSubmit } = useForm<{ country: string }>();

  return (
    <form onSubmit={handleSubmit((d) => console.log(d))}>
      <Controller
        name="country"
        control={control}
        rules={{ required: "国を選択してください" }}
        render={({ field }) => (
          <Select
            options={options}
            value={options.find((o) => o.value === field.value)}
            onChange={(opt) => field.onChange(opt?.value)}
          />
        )}
      />
      <button type="submit">送信</button>
    </form>
  );
}

確認方法

  1. 上記コンポーネントを Vite プロジェクトに配置し、npm run dev で起動する
  2. 何も入力せず送信 → 各フィールドの必須エラーが表示されること
  3. 不正な形式のメールを入力 → 形式エラーが表示されること
  4. パスワードと確認用を食い違わせる → passwordConfirm にエラーが出ること
  5. すべて正しく入力して送信 → console.log に値が出力され、エラーが消えること

注意点

  • 既定は非制御。他ライブラリと組む場合は Controller を挟む前提で設計する
  • watch の乱用は再レンダリング増につながる。表示更新に必要な範囲に絞る
  • 初期値は useForm({ defaultValues }) で渡す。編集画面では API 取得後に reset(data) で流し込むのが定石
  • スキーマと型の単一ソース化を狙うなら Zod との併用を最初から入れておくと後が楽

まとめ

React Hook Form を使うと、useState の羅列・手書きバリデーション・再レンダリング問題という「フォームあるある」の大半を解消できます。まず register と handleSubmit で置き換え、次に Zod でバリデーションと型を1か所に集約する、という順で導入するのがおすすめです。小さいフォームでも恩恵はありますが、フィールド数が増えるほど差が効いてきます。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。