⚛️

React Hook Form + Zod の紹介と導入【Next.js】

に公開

はじめに

先日、React の勉強会で、フォーム実装について取り上げました 📝

フォーム処理とバリデーションは、Web アプリケーション開発において重要でありながら、意外と複雑になりがちな要素です。

今回は、React Hook Form と Zod について調査したので、基礎的な内容をまとめました!

時間の節約になれば、嬉しいです 🙌

React Hook Form とは?

https://react-hook-form.com/

React で、複雑なフォーム処理を、シンプルかつ効率的に実装できるのが、React Hook Form です

フォーム状態の管理、バリデーション、エラーハンドリングなどを簡潔に実装でき、
不要な再レンダリングを抑制する設計になっています!

React Hook Form(以下、RHF)は、パフォーマンスを重視し、
自力で実装するよりもコード量を少なく済ませることができるのが特徴の一つです。

Zod とは?

https://zod.dev/

Zod は、TypeScript ファーストのスキーマ検証(バリデーション)ライブラリです

フォームのバリデーションルールを宣言的に定義でき、
React Hook Form と組み合わせることで、型安全なフォーム実装が可能になります!

Zod の特徴は以下の通りです:

  • TypeScript との優れた連携
  • 宣言的なバリデーションルール定義
  • カスタムエラーメッセージの柔軟な設定
  • 複雑なバリデーションにも対応(ネストやリスト、条件付きなど)

React Hook Form の必要性

そもそも、なぜ React Hook Form が必要なのでしょうか?
実際に、RHF を使わないフォーム実装と比較してみましょう。

RHF を使わないフォーム実装の例

従来の React でのフォーム実装は、以下のようになりがちです:

import { useState } from "react";

function ContactForm() {
  // 各フィールドの状態管理
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [message, setMessage] = useState("");

  // エラー状態管理
  const [errors, setErrors] = useState({
    name: "",
    email: "",
    message: "",
  });

  // 送信状態管理
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submitStatus, setSubmitStatus] = useState("");

  // バリデーション関数
  const validateForm = () => {
    let valid = true;
    const newErrors = {
      name: "",
      email: "",
      message: "",
    };

    // 名前のバリデーション
    if (name.length < 2) {
      newErrors.name = "お名前は2文字以上で入力してください";
      valid = false;
    }

    // メールのバリデーション
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      newErrors.email = "有効なメールアドレスを入力してください";
      valid = false;
    }

    // メッセージのバリデーション
    if (message.length < 10) {
      newErrors.message = "お問い合わせ内容は10文字以上で入力してください";
      valid = false;
    }

    setErrors(newErrors);
    return valid;
  };

  // 送信ハンドラ
  const handleSubmit = async (e) => {
    e.preventDefault();

    if (!validateForm()) {
      return;
    }

    setIsSubmitting(true);

    try {
      // API呼び出し
      const response = await fetch("/api/contact", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ name, email, message }),
      });

      const data = await response.json();

      if (response.ok) {
        setSubmitStatus("success");
        // フォームリセット
        setName("");
        setEmail("");
        setMessage("");
      } else {
        setSubmitStatus("error");
      }
    } catch (error) {
      setSubmitStatus("error");
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">お名前</label>
        <input
          id="name"
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
        {errors.name && <p className="error">{errors.name}</p>}
      </div>

      <div>
        <label htmlFor="email">メールアドレス</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
        {errors.email && <p className="error">{errors.email}</p>}
      </div>

      <div>
        <label htmlFor="message">お問い合わせ内容</label>
        <textarea
          id="message"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
        />
        {errors.message && <p className="error">{errors.message}</p>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "送信中..." : "送信する"}
      </button>

      {submitStatus === "success" && (
        <p className="success">お問い合わせを受け付けました。</p>
      )}

      {submitStatus === "error" && (
        <p className="error">エラーが発生しました。再度お試しください。</p>
      )}
    </form>
  );
}

上記のコードでは、以下のような課題があります:

  1. 冗長な状態管理:各フィールドに対して個別の state が必要
  2. 手動のバリデーション:バリデーションロジックを自前で実装する必要がある
  3. 再レンダリングの多発:各入力ごとに再レンダリングが発生
  4. TypeScript との統合が弱い:型安全性を確保するのが難しい
  5. テストの複雑さ:多くの状態と条件分岐があるため、テストが複雑になる

自力で全部やろうとすると途端に複雑になります。。

とはいえ、適切なバリデーションやメッセージの表示は、フォームの実装には重要なポイントです!

React Hook Form の導入手順(Next.js)

それでは、React Hook Form と Zod を使って、
同じフォームをより効率的に実装してみましょう!

以下の手順で導入していきましょう。

1. ライブラリのインストール

npm install react-hook-form @hookform/resolvers zod

これにより、以下のライブラリがインストールされます:

  • react-hook-form: フォーム管理のコアライブラリ
  • zod: バリデーションスキーマライブラリ
  • @hookform/resolvers: React Hook Form と Zod を連携させるアダプター

2. バリデーションスキーマの定義

まず、Zod を使ってバリデーションルールを定義します:

import * as z from "zod";

// バリデーションスキーマの定義
export const contactSchema = z.object({
  name: z
    .string()
    .min(2, { message: "お名前は2文字以上で入力してください" })
    .max(50, { message: "お名前は50文字以内で入力してください" }),
  email: z
    .string()
    .email({ message: "有効なメールアドレスを入力してください" }),
  message: z
    .string()
    .min(10, { message: "お問い合わせ内容は10文字以上で入力してください" })
    .max(1000, { message: "お問い合わせ内容は1000文字以内で入力してください" }),
});

// 型定義のエクスポート
export type ContactFormValues = z.infer<typeof contactSchema>;

このスキーマでは、各フィールドの入力条件を宣言的に定義しています:

  • name: 2〜50 文字の名前
  • email: 有効なメールアドレス形式
  • message: 10〜1000 文字のお問い合わせ内容

Zod の強力な点は、スキーマから TypeScript の型を自動生成できることです。
z.infer<typeof contactSchema> が、そのフォーム値の型定義になります。

3. React Hook Form の実装

次に、React Hook Form と定義したスキーマを使ってフォームを実装します:

import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { contactSchema, ContactFormValues } from "./contactSchema";

function ContactForm() {
  const [submitStatus, setSubmitStatus] = useState<
    "idle" | "submitting" | "success" | "error"
  >("idle");

  // React Hook Formの初期化
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<ContactFormValues>({
    resolver: zodResolver(contactSchema),
    defaultValues: {
      name: "",
      email: "",
      message: "",
    },
  });

  // 送信処理
  const onSubmit = async (data: ContactFormValues) => {
    setSubmitStatus("submitting");

    try {
      // API呼び出し処理...
      setSubmitStatus("success");
      reset(); // フォームリセット
    } catch (error) {
      setSubmitStatus("error");
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="name">お名前</label>
        <input id="name" {...register("name")} />
        {errors.name && <p>{errors.name.message}</p>}
      </div>

      {/* email、messageフィールドも同様 */}

      <button type="submit" disabled={submitStatus === "submitting"}>
        {submitStatus === "submitting" ? "送信中..." : "送信する"}
      </button>

      {/* 成功・エラーメッセージ */}
    </form>
  );
}

このコードでは、React Hook Form の主要な機能を活用しています:

  • useForm フックでフォームを初期化
  • zodResolver を使って Zod スキーマと連携
  • register 関数で入力フィールドを登録
  • handleSubmit で送信処理をラップ(バリデーション成功時のみ実行)
  • reset でフォームの値をリセット
  • formState.errors でバリデーションエラーにアクセス

React Hook Form の主なメリットは?

このように実装することで、以下のようなメリットが得られます:

  1. コード量の削減:状態管理やバリデーションのボイラープレートコードが大幅に削減
  2. パフォーマンスの向上:不要な再レンダリングを抑制する設計
  3. 型安全性:Zod と TypeScript の連携により、強力な型推論が可能
  4. クリーンな実装:宣言的なバリデーションとフォーム管理
  5. エラーハンドリングの簡略化:エラーメッセージの管理が容易

おまけ:ベータ版の<Form />コンポーネントで Server Actions を使う方法

React Hook Form のベータ版では、新しい<Form />コンポーネントが導入されています。

これは React 19 の Server Actions と直接統合できる新機能です

https://react-hook-form.com/docs/useform/form

主な特徴は以下の通りです:

  • Server Actions との連携:React 19 の Server Actions と直接統合
  • 自動化された FormData 処理:フォームの値を自動的に FormData に変換
  • プログレッシブ・エンハンスメント:JavaScript が無効でも機能するフォーム

簡単な実装例を見てみましょう:

"use client";

import { startTransition, useActionState } from "react";
import { useForm, Form } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { newsletterSchema } from "./schemas";

export default function NewsletterForm() {
  // Server Action の状態管理
  const [state, formAction, isPending] = useActionState(subscribeToNewsletter, {
    status: null,
    message: "",
  });

  // React Hook Form の設定
  const {
    register,
    control,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(newsletterSchema),
  });

  return (
    <Form
      control={control}
      onSubmit={({ formData }) => startTransition(() => formAction(formData))}
    >
      <input id="email" {...register("email")} disabled={isPending} />
      {errors.email && <p>{errors.email.message}</p>}

      <button type="submit" disabled={isPending}>
        {isPending ? "処理中..." : "購読する"}
      </button>

      {/* 成功・エラーメッセージ */}
    </Form>
  );
}

この例では:

  1. React 19 の useActionState を使って Server Action の状態を管理
  2. React Hook Form の <Form> コンポーネントを使用
  3. control プロパティを通じて RHF の機能を <Form> に連携
  4. onSubmit で Server Action を実行(formData が自動的に生成される)

これにより、Client Components と Server Components(RSC) を橋渡しする、
より宣言的なフォーム実装が可能になりますね!

おわりに

最後まで読んでいただき、ありがとうございます 🥳

ハンズオン形式で、
実際に手を動かしてフォーム実装を学習したい場合は、以下の教材もチェックしてみてください!!

https://zenn.dev/kazzyfrog/books/handsonbook-nextjs-form

この記事が、少しでも参考になれば嬉しいです!

そして、もし、間違いや補足情報などがありましたら、ぜひコメントを追加してください!

Happy Hacking :)

参考

https://devblog.thebase.in/entry/2024/12/20/110000
https://react-hook-form.com/docs/useform/form
https://zenn.dev/cybozu_frontend/articles/think-about-pe
https://zenn.dev/tenkei/articles/a043fed8c11b42
https://zenn.dev/dddots_ryo/articles/785dc24f81ac15

b13o Tech Blog

Discussion