Use Cases (更新: 2026/6/2)

Claude CodeでReact Hook Formを安全に実装する入門ガイド

React Hook FormとZodをClaude Codeで実装する手順。useForm、検証、エラー表示、送信状態、テストまで解説。

Claude CodeでReact Hook Formを安全に実装する入門ガイド

React Hook FormをClaude Codeに任せる前に決めること

React Hook Formは、Reactでフォームを作るための軽量なライブラリです。入力値をすべてReactのstateで細かく管理するのではなく、HTMLフォームの仕組みを生かしながら、registerhandleSubmitformStateで入力、送信、エラーを扱います。初心者がつまずきやすい「どこで値を持つのか」「いつバリデーションするのか」「送信中にボタンをどう止めるのか」を整理しやすいのが利点です。

Claude Codeと組み合わせると、フォーム部品、Zodスキーマ、テスト、API側の再検証まで一気に下書きできます。ただし、フォームは収益や問い合わせに直結します。Claude Codeに「フォームを作って」とだけ頼むと、エラー表示が読みにくい、二重送信を止めていない、サーバー側の検証が抜けている、という実務上の問題が残りがちです。

この記事では、問い合わせフォームを例に、React Hook FormのuseForm、Zod resolver、エラー表示、送信状態、テストしやすい分割、Claude Codeへの安全な依頼文までまとめます。React全体の進め方はClaude CodeでReact開発を爆速にする方法、Zod単体の考え方はClaude CodeでZodバリデーションを効率的に実装するもあわせて読むと理解しやすくなります。

全体像: スキーマを中心にフォームを組む

React Hook Formはフォーム操作の土台です。Zodは「どんな入力を正しいとみなすか」をコードで表すスキーマです。@hookform/resolvers/zodzodResolverを使うと、React Hook Formの検証タイミングでZodスキーマを実行できます。

flowchart TD
  A["ユーザーが入力する"] --> B["React Hook Form register"]
  B --> C["zodResolverでスキーマ検証"]
  C --> D{"正しい入力か"}
  D -->|いいえ| E["field errorsを表示"]
  D -->|はい| F["handleSubmitで送信"]
  F --> G["APIでも同じスキーマで再検証"]
  G --> H["保存・通知・CRM連携"]

初心者向けに言い換えると、useFormはフォームの受付係、Zodスキーマは入力ルール表、resolverは受付係とルール表をつなぐ通訳です。Claude Codeに実装を頼む場合も、この3つを分けて伝えると、後から項目追加や文言修正をしても壊れにくくなります。

公式情報としては、React Hook FormのuseFormドキュメント、resolverのReact Hook Form Resolvers、Zodの公式APIドキュメント、Reactの<input>ドキュメントを確認してください。Claude Code側の操作はClaude Code overviewcommandsが起点になります。

コピペで動くZodスキーマ

まず、入力ルールだけを別ファイルにします。ここでは「名前、メール、カテゴリ、本文、連絡同意」を持つ問い合わせフォームにします。z.inferは、ZodスキーマからTypeScriptの型を作る仕組みです。型とバリデーションを二重管理しないために使います。

// src/features/inquiry/inquirySchema.ts
import { z } from "zod";

export const inquirySchema = z.object({
  name: z
    .string()
    .trim()
    .min(1, "お名前を入力してください")
    .max(80, "お名前は80文字以内で入力してください"),
  email: z
    .string()
    .trim()
    .email("有効なメールアドレスを入力してください"),
  category: z.enum(["consulting", "support", "billing"], {
    error: "カテゴリを選択してください",
  }),
  message: z
    .string()
    .trim()
    .min(10, "本文は10文字以上で入力してください")
    .max(1000, "本文は1000文字以内で入力してください"),
  agreeToContact: z.boolean().refine((value) => value, {
    message: "連絡への同意が必要です",
  }),
});

export type InquiryFormValues = z.infer<typeof inquirySchema>;

カテゴリをz.enumにしているのは、自由入力ではなく決まった値だけを送るためです。実務では、この値をCRM、Slack通知、メールテンプレートの分岐に使います。Claude Codeに頼むときは、「表示ラベルは日本語、送信値は英語の固定値」と指定しておくと、後続処理で扱いやすくなります。

useFormで入力、エラー、送信状態を扱う

次はフォーム本体です。mode: "onBlur"は、入力欄からフォーカスが外れたタイミングで検証する設定です。初心者向けの問い合わせフォームでは、1文字入力するたびに赤いエラーを出すより、入力後に確認するほうがうるさくありません。送信時にはhandleSubmitが最終検証を行います。

// src/features/inquiry/InquiryForm.tsx
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { inquirySchema, type InquiryFormValues } from "./inquirySchema";

async function sendInquiry(values: InquiryFormValues) {
  const response = await fetch("/api/inquiry", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(values),
  });

  if (!response.ok) {
    throw new Error("Failed to send inquiry");
  }
}

export function InquiryForm() {
  const {
    register,
    handleSubmit,
    reset,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<InquiryFormValues>({
    resolver: zodResolver(inquirySchema),
    mode: "onBlur",
    defaultValues: {
      name: "",
      email: "",
      message: "",
      agreeToContact: false,
    },
  });

  const onSubmit = async (values: InquiryFormValues) => {
    try {
      await sendInquiry(values);
      reset();
    } catch {
      setError("root", {
        type: "server",
        message: "送信に失敗しました。時間をおいてもう一度お試しください。",
      });
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <div>
        <label htmlFor="name">お名前</label>
        <input
          id="name"
          autoComplete="name"
          aria-invalid={errors.name ? "true" : "false"}
          aria-describedby={errors.name ? "name-error" : undefined}
          {...register("name")}
        />
        {errors.name && (
          <p id="name-error" role="alert">
            {errors.name.message}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email">メールアドレス</label>
        <input
          id="email"
          type="email"
          autoComplete="email"
          aria-invalid={errors.email ? "true" : "false"}
          aria-describedby={errors.email ? "email-error" : undefined}
          {...register("email")}
        />
        {errors.email && (
          <p id="email-error" role="alert">
            {errors.email.message}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="category">相談内容</label>
        <select
          id="category"
          aria-invalid={errors.category ? "true" : "false"}
          aria-describedby={errors.category ? "category-error" : undefined}
          {...register("category")}
        >
          <option value="">選択してください</option>
          <option value="consulting">導入相談</option>
          <option value="support">技術サポート</option>
          <option value="billing">請求・契約</option>
        </select>
        {errors.category && (
          <p id="category-error" role="alert">
            {errors.category.message}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="message">本文</label>
        <textarea
          id="message"
          rows={6}
          aria-invalid={errors.message ? "true" : "false"}
          aria-describedby={errors.message ? "message-error" : undefined}
          {...register("message")}
        />
        {errors.message && (
          <p id="message-error" role="alert">
            {errors.message.message}
          </p>
        )}
      </div>

      <label>
        <input type="checkbox" {...register("agreeToContact")} />
        返信のために入力内容を確認することに同意します
      </label>
      {errors.agreeToContact && (
        <p role="alert">{errors.agreeToContact.message}</p>
      )}

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

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

ポイントは、エラーをrole="alert"で表示し、入力欄とエラー文をaria-describedbyで結ぶことです。これは見た目だけでなく、スクリーンリーダーを使う読者にも重要です。アクセシビリティ全体はClaude Codeでアクセシビリティを改善するも参考になります。

API側でも同じスキーマで再検証する

フロント側の検証は、ユーザー体験を良くするためのものです。セキュリティやデータ品質の最後の砦ではありません。ブラウザの検証は迂回できるため、API側でも同じスキーマで検証します。以下はNext.js Route Handlerの例です。

// app/api/inquiry/route.ts
import { NextResponse } from "next/server";
import { inquirySchema } from "@/features/inquiry/inquirySchema";

export async function POST(request: Request) {
  const payload = await request.json().catch(() => null);
  const parsed = inquirySchema.safeParse(payload);

  if (!parsed.success) {
    return NextResponse.json(
      {
        error: "Invalid inquiry",
        fields: parsed.error.flatten().fieldErrors,
      },
      { status: 400 },
    );
  }

  // TODO: DB保存、メール通知、CRM連携などをここに追加する
  return NextResponse.json({ ok: true });
}

Claude CodeにAPIも作らせるなら、「フロントと同じinquirySchemaを使う」「失敗時は400でfield errorsを返す」「メール送信やCRM連携はTODOかモックにする」と明記します。最初から外部サービスまでつなぐと、秘密鍵、リトライ、重複送信の扱いまで広がり、レビューが難しくなります。

テストしやすいフォームにする

フォームは目視だけでは壊れます。特に、必須項目のエラー、送信成功、送信失敗、二重送信防止はテストで押さえたいところです。React Testing LibraryとVitestを使う場合、以下のように「空送信でエラーが出る」「正しい入力でfetchが呼ばれる」を確認できます。

// src/features/inquiry/InquiryForm.test.tsx
import { afterEach, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { InquiryForm } from "./InquiryForm";

afterEach(() => {
  vi.unstubAllGlobals();
});

test("空のまま送信するとバリデーションエラーを表示する", async () => {
  render(<InquiryForm />);

  await userEvent.click(screen.getByRole("button", { name: "送信する" }));

  expect(await screen.findAllByRole("alert")).toHaveLength(5);
});

test("正しい入力ならAPIへ送信する", async () => {
  const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 200 }));
  vi.stubGlobal("fetch", fetchMock);
  render(<InquiryForm />);

  await userEvent.type(screen.getByLabelText("お名前"), "Masa");
  await userEvent.type(screen.getByLabelText("メールアドレス"), "masa@example.com");
  await userEvent.selectOptions(screen.getByLabelText("相談内容"), "consulting");
  await userEvent.type(
    screen.getByLabelText("本文"),
    "React Hook Formの導入相談をしたいです。",
  );
  await userEvent.click(
    screen.getByLabelText("返信のために入力内容を確認することに同意します"),
  );
  await userEvent.click(screen.getByRole("button", { name: "送信する" }));

  expect(fetchMock).toHaveBeenCalledWith(
    "/api/inquiry",
    expect.objectContaining({ method: "POST" }),
  );
});

Claude Codeには「まず失敗するテストを書いてから実装して」と頼めます。テスト方針はClaude CodeでPlaywrightテストを自動化するや、フォーム完了の計測はClaude Codeでアナリティクス実装を効率化するとつなげると、問い合わせ数の改善まで追いやすくなります。

Claude Codeへの依頼文テンプレート

Claude Codeにフォームを変更してもらうときは、実装対象、触ってよい範囲、検証コマンド、禁止事項を一緒に渡します。フォームは小さく見えて、型、UI、API、テスト、計測にまたがるからです。

React Hook FormとZodで問い合わせフォームを実装してください。

条件:
- src/features/inquiry 配下だけを変更する
- useForm、zodResolver、TypeScript型を使う
- フィールドは name, email, category, message, agreeToContact
- エラーは role="alert" で表示し、aria-describedby を設定する
- 送信中はボタンを disabled にし、二重送信を防ぐ
- API側でも同じZodスキーマで safeParse する
- Vitest + Testing Library のテストを追加する

確認:
- npm test -- InquiryForm
- npm run typecheck

やらないこと:
- UIライブラリを新規追加しない
- 既存のカテゴリ値を変えない
- 秘密鍵や本番API連携を実装しない

修正依頼ではさらに具体的にします。「カテゴリを1つ追加して」ではなく、「表示ラベルは『研修相談』、送信値はtraining、Zod、select、テスト、APIの許可値を同時に更新して」と書きます。Claude Codeは関連ファイルを探してくれますが、フォームでは人間側が契約を言語化したほうが事故が減ります。

使いどころと設計の違い

ユースケース向いている構成注意点
問い合わせフォームZod + React Hook Form + API再検証送信成功後の計測と通知を分ける
プロフィール編集defaultValuesに既存データを入れる保存後にreset(savedValues)でdirty状態を戻す
購入前アンケートselect、radio、checkboxを組み合わせる選択肢の送信値を商品やCRMの値と合わせる
管理画面の検索フォームバリデーションを軽めにしてURL queryへ反映入力ごとにAPIを叩きすぎない

3つ以上の用途に共通するのは、「画面の都合」と「送信データの都合」を混ぜないことです。表示ラベルは日本語で構いませんが、送信値は英語の固定値やIDにしておくと、集計や外部連携で困りません。Claude Codeには、UI文言と送信値を表で渡すと、後から読み返せる実装になります。

よくある失敗と落とし穴

1つ目は、Zodスキーマをフロントだけに置いてAPIで使わないことです。フォーム上では弾けても、直接APIを叩かれたら不正な値が入ります。共通スキーマをfeatureslibに置き、フロントとAPIの両方からimportしてください。

2つ目は、isSubmittingがすぐ戻ってしまう実装です。onSubmitの中で非同期処理をawaitしないと、送信中状態を正しく保てません。メール送信、DB保存、fetchは必ずPromiseとして返し、try/catchで失敗をsetError("root", ...)へ寄せます。

3つ目は、エラー文を入力欄の近くに置かないことです。画面上部に「入力エラーがあります」だけ出しても、読者はどこを直すべきかわかりません。各フィールドの直下にエラーを出し、必要なら上部に概要を追加します。

4つ目は、Claude Codeに既存のフォーム部品を無視させることです。デザインシステム、TextFieldButton、通知コンポーネントがあるなら、先に「既存部品を使う」と指定します。新しいUIライブラリを入れるのは最後の手段です。

5つ目は、成功後の導線を忘れることです。問い合わせフォームなら、送信完了メッセージ、サンクスページ、メール通知、アナリティクスのgenerate_leadまでが成果です。送信ボタンのクリックだけを成果にすると、バリデーションエラーや通信失敗も成功に見えてしまいます。

収益導線として見るフォーム改善

フォーム改善は、見た目のきれいさだけでは判断できません。記事から無料PDFへ進む、教材ページを見る、導入相談を送る、という流れの中で、どこで離脱しているかを見る必要があります。Claude Codeで入力項目を減らしたり、エラー文を直したりする前に、「何を成果にするか」を決めてください。

Claude Codeの導入やフォーム改善をチームで進めたい場合は、研修・導入相談で運用設計から相談できます。自分で試す場合は、教材一覧からプロンプトテンプレートやClaude Code入門教材を確認してください。フォームは小さな部品ですが、問い合わせ、購入、メール登録を支える収益導線の中心です。

この記事で紹介した内容を実際に試した結果

Masaがこの構成を小さな問い合わせフォームで試したところ、最初に効いたのはisSubmittingよりも「スキーマを1ファイルに寄せる」ことでした。カテゴリ追加時に、selectだけ更新してAPIの許可値を忘れる失敗を防げたからです。次に効いたのは、空送信と成功送信のテストです。Claude Codeに修正を頼んだ後でも、エラー表示の消し忘れやfetchの呼び出し漏れをすぐ検出できました。最終的には、フォーム実装を「UI」ではなく「入力契約」として扱うほうが、保守もレビューも速くなります。

#Claude Code #React Hook Form #React #フォーム #バリデーション
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。