10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React Hook Form 入門 Part 2 — バリデーション設計を実務レベルに引き上げる

10
Posted at

シリーズ構成

  • Part 1: RHF の仕組みを理解してから使う → [リンク]
  • Part 2: バリデーション設計を実務レベルに引き上げる(本記事)
  • Part 3: Controller と useFieldArray(準備中)

Part 1 では register が何をしているのか、なぜ RHF が速いのかを整理しました。

今回のテーマはバリデーションです。「動くフォーム」を作るだけなら Part 1 の内容で十分ですが、実際のプロダクトではそうはいきません。エラーをいつ出すか、どう書くか、サーバーエラーをどう扱うか——細かいところに手が届いて初めて「使えるフォーム」になります。

今回のゴール

この記事では以下を理解することを目的とします:

  • rule-based と schema-based、バリデーションの 2 つのアプローチの使い分け
  • Zod を使った型安全なバリデーション設計
  • エラーの出し方・メッセージの書き方・アクセシビリティの基本
  • cross-field validation とサーバーエラーの扱い方

バリデーションの全体像を整理する

まずは、React Hook Form におけるバリデーションの全体像を整理します。
大きく分けて、以下の 2 つのアプローチがあります。

Input → RHF → Resolver(省略可)→ Schema / Rules → Error → UI

resolver を使わない場合は register のルールが直接評価されます。Zod などの resolver を挟むと、バリデーションロジックがスキーマに委譲されます。

rule-based — register に直接書く

<input
  {...register("email", {
    required: "メールアドレスは必須です",
    pattern: {
      value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
      message: "正しいメール形式を入力してください",
    },
  })}
/>

追加ライブラリが不要で、小さなフォームならこれで十分です。ただしフィールドが増えるにつれ、ルールがコンポーネントのあちこちに散らばっていきます。

schema-based — Zod や Yup でまとめて定義する

const schema = z.object({
  email: z
    .string()
    .min(1, "メールアドレスは必須です")
    .email("正しいメール形式を入力してください"),
});

バリデーションのロジックがスキーマに集約されます。型定義も z.infer<typeof schema> で自動生成できるため、手書きの type FormValues = { ... } が不要になります。

どちらを選ぶか

項目 rule-based schema-based
追加ライブラリ 不要 必要(Zod / Yup)
型安全性 限定的 スキーマから自動生成できる
再利用性 低い(フォームごとに書き直し) 高い(API 型定義と共有できる)
複雑なルール 冗長になりやすい refine などで柔軟に対応できる
向いているケース 小規模・単発のフォーム 中〜大規模・使い回しが必要なフォーム

簡単なフォームなら register のルールでも十分です。 再利用性や保守性を考えるなら schema validation が有利です。

複数の画面で同じバリデーションロジックを使い回したい・バックエンドと型を共有したい・ルールが複雑になってきた——そのタイミングで Zod に移行するのが現実的な判断です。最初から schema-based にする必要はありません。


Zod + RHF の実装

schema-based の選択肢として Zod と Yup がよく挙げられますが、現在は Zod が採用されることが多いです。理由は次の 3 点です:

  • TypeScript との親和性が高い(型推論が自然に効く)
  • 型定義とバリデーションを一元化できる
  • ランタイムと型の乖離が起きにくい

セットアップ

npm install 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(2, "2文字以上入力してください"),
  email: z.string().min(1, "必須です").email("メール形式が正しくありません"),
  password: z.string().min(8, "8文字以上入力してください"),
});

// スキーマから型を生成 — 手書き不要
type FormValues = z.infer<typeof schema>;

export default function RegisterForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormValues>({
    resolver: zodResolver(schema), // これだけで Zod に委譲される
  });

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

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

      <input placeholder="メール" {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="password" placeholder="パスワード" {...register("password")} />
      {errors.password && <p>{errors.password.message}</p>}

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

resolver: zodResolver(schema) を渡すだけで、register 側にルールを書く必要がなくなります。スキーマが唯一のバリデーションの源泉になるので、ルールの重複や矛盾が起きません。


エラーの出し方を設計する

バリデーションロジックが正しくても、エラーの出し方が悪いと UX を損ないます。

タイミングの制御

useFormmode オプションで制御します。

useForm({
  mode: "onBlur", // フォーカスが外れたとき
})
mode タイミング 向いているケース
onSubmit 送信ボタンを押したとき(デフォルト) シンプルな問い合わせフォーム
onBlur フィールドからフォーカスが外れたとき 登録・設定フォーム全般
onChange 入力のたびにリアルタイム パスワード強度メーターなど

onBlur が実務でよく選ばれる理由は、入力中に赤くなるのは煩わしく、送信後だけだとフィードバックが遅い——その中間点にあるからです。

onChange はリアルタイム検証ができる反面、入力のたびにバリデーションが走るため、重い処理(非同期チェックなど)と組み合わせるときは注意が必要です。

エラーメッセージの書き方

「何が間違っているか」だけでなく「どう直せばいいか」まで書くのが原則です。

NG OK
「入力が正しくありません」 「メールアドレスの形式で入力してください(例: user@example.com)」
「パスワードエラー」 「パスワードは8文字以上で入力してください」
「必須項目です」 「お名前を入力してください」

プロダクト全体でメッセージの文体を統一することも重要です。「〜してください」で統一するか「〜が必要です」で統一するか——どちらでも構いませんが、混在するのが一番よくないです。

アクセシビリティの最低限

アクセシビリティは後回しにされがちですが、実務では必須要件になるケースも多いため、最低限の対応は最初から入れておくのが安全です。

エラーをビジュアルだけで伝えると、スクリーンリーダーユーザーに届きません。最低限 aria-describedby で input とエラーを紐づけましょう。

<input
  id="email"
  aria-describedby={errors.email ? "email-error" : undefined}
  aria-invalid={!!errors.email}
  {...register("email")}
/>
{errors.email && (
  <p id="email-error" role="alert">
    {errors.email.message}
  </p>
)}

role="alert" をつけると、エラーが表示された瞬間にスクリーンリーダーが読み上げます。aria-invalid はフィールドが無効であることをブラウザと支援技術に伝えます。


Cross-field validation — フィールドをまたぐ検証

confirm password

const schema = z
  .object({
    password: z.string().min(8, "8文字以上入力してください"),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "パスワードが一致しません",
    path: ["confirmPassword"], // エラーをどのフィールドに紐づけるか
  });

日付の範囲チェック

const schema = z
  .object({
    startDate: z.string(),
    endDate: z.string(),
  })
  .refine((data) => new Date(data.endDate) > new Date(data.startDate), {
    message: "終了日は開始日より後の日付を指定してください",
    path: ["endDate"],
  });

日付の比較はタイムゾーンの影響を受けるため、実務では date-fns などの日付ライブラリを使う方が安全です。new Date() による文字列比較は、環境によって意図しない挙動をすることがあります。

Zod の .refine() は複数フィールドの値をまとめて受け取れるため、条件分岐をスキーマ側に閉じ込められます。rule-based でこれをやろうとすると validate オプションを使う必要があり、コードが複雑になりがちです。


サーバーエラーを setError で扱う

クライアントのバリデーションを通過しても、API 側でエラーが返ることがあります。RHF にはそれを受け取る setError があります。フィールド単位のエラーか、フォーム全体のエラーかを明確に分けることで、UI の設計がシンプルになります。

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

const onSubmit = async (data: FormValues) => {
  try {
    await registerUser(data);
  } catch (err) {
    if (err.code === "EMAIL_ALREADY_EXISTS") {
      // 特定フィールドへのエラー
      setError("email", {
        type: "server",
        message: "このメールアドレスはすでに登録されています",
      });
    } else {
      // フォーム全体へのエラー
      setError("root", {
        type: "server",
        message: "登録に失敗しました。しばらく時間をおいて再試行してください",
      });
    }
  }
};

root はフィールドに紐づかないグローバルエラー用の特別なキーです。

{errors.root && (
  <p role="alert">{errors.root.message}</p>
)}

使い分けの目安:

ケース 使うキー
メールが重複している、ユーザー名が使用済みなど 該当フィールドに setError
認証エラー、ネットワークエラー、権限エラーなど rootsetError

よくあるハマりどころ

実際に使い始めると詰まりやすいポイントをまとめておきます。

schema と register のルールを混在させてしまう
zodResolver を使っている場合、register 側にもルールを書くと二重管理になります。resolver を使ったら register 側のルールは書かないと決めておきましょう。

errors.xxx.messageundefined になる
Zod のスキーマでメッセージを設定し忘れているか、フィールド名がスキーマと一致していないことが多いです。z.string() だけだとデフォルトメッセージが英語になる場合があります。

defaultValues とスキーマの型が合っていない
useFormdefaultValues は型推論が効きにくく、スキーマと微妙にずれていても気づきにくいです。defaultValuesFormValues 型で明示的に指定しておくと安全です。


まとめ

  1. スキーマは別ファイルに切り出すschema.ts に分離しておくと API 型定義やテストから再利用しやすくなります
  2. クライアントとサーバーのバリデーションは役割が違う — クライアントは即時フィードバック、サーバーは最終チェックと割り切る
  3. エラーメッセージはコードと同じくらい大切 — 文体の統一・具体的な修正方法の提示・アクセシビリティの確保、この 3 点でフォームの完成度が変わります

10
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?