Communityコーディング&開発github.com

フォームとバリデーション

react-hook-form、zod、shadcn/ui Formを使用したアクセシブルなフォーム。インラインエラー、送信待機・無効状態、成功フィードバック、共有スキーマによるサーバーサイドバリデーションを網羅します。

フォームとバリデーション とは?

このスキルは、react-hook-form、zod、@hookform/resolvers/zod、shadcn/uiのFormコンポーネントを組み合わせて、アクセシブルで堅牢なフォームを構築するための包括的なガイドです。単一のzodスキーマを型、クライアント側バリデーション、サーバー側バリデーションの唯一の真実源として使用します。主なルールとして、スキーマファーストのアプローチ、サーバーサイドでの再検証、すべてのフィールドへのラベルとインラインエラーの表示、onTouchedモードによる適切なバリデーションタイミング、送信ボタンの適切な状態管理、成功・失敗時のフィードバック、適切な入力タイプやオートコンプリート属性の使用が挙げられます。KODRAスキャフォールドにすでにインストールされていることを前提としており、実践的なコード例(連絡フォーム)とサーバー側バリデーションの実装例、エラーメッセージのスタイルガイド、状態チェックリストを含んでいます。

対応~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/ko-system/kodra-skills/tree/HEAD/skills/forms-validation

Installed? Explore more コーディング&開発 skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

お気に入りのAIに質問する

このエージェントスキルを事前に読み込んだ状態で新しいチャットを開きます。

ドキュメント

フォームとバリデーション は何をしますか?

スタック: react-hook-form + zod + @hookform/resolvers/zod + shadcn/ui Form コンポーネント(KODRA スキャフォールドに既にインストール済み)。1つの zod スキーマが型、クライアント側バリデーション、サーバー側バリデーションの唯一の真実源です。

ルール

  1. スキーマファースト。zod スキーマを定義し、z.infer で TypeScript 型を導出します。共有スキーマは src/lib/schemas/<name>.ts に配置し、サーバールートが同じファイルをインポートできるようにします。
  2. サーバー側でもバリデーションする。クライアントバリデーションは UX のためです。サーバールート、エッジファンクション、またはデータベース制約がセキュリティ境界です。同じスキーマでボディを再パースし、フィールドエラーを返します。
  3. すべてのフィールドに表示可能なラベル、形式が明確でない場合の役立つ説明、フィールドの下にインラインエラーを表示します。
  4. バリデーションのタイミング: mode: "onTouched"(最初のブラー後にバリデーション、その後は変更時)。初回入力中にユーザーを驚かせないようにします。
  5. 送信ボタンは送信開始まで有効のままにし、その後は送信待機状態を表示して無効にし、二重送信を防止します。
  6. 成功時は何が起こったかを表示し(トーストまたはインラインメッセージ)、リセットまたはナビゲートします。失敗時はユーザーの入力を保持し、フォームの近くにエラーを表示し、最初の無効なフィールドにフォーカスを移動します(react-hook-form がデフォルトで行います)。
  7. 適切な入力タイプを使用: type="email"、inputMode="numeric"、autoComplete トークン(name、email、tel、street-address、postal-code、one-time-code、new-password)により、ブラウザと電話が支援します。

標準フォーム

// src/lib/schemas/contact.ts
import { z } from "zod";

export const contactSchema = z.object({
  name: z.string().trim().min(2, "氏名を入力してください"),
  email: z.string().trim().email("有効なメールアドレスを入力してください(例: [email protected])"),
  phone: z
    .string()
    .trim()
    .regex(
      /^0\d{8,9}$/,
      "タイの電話番号を0から始めて入力してください(9~10桁)",
    )
    .optional()
    .or(z.literal("")),
  topic: z.enum(["booking", "billing", "other"], {
    required_error: "トピックを選択してください",
  }),
  message: z
    .string()
    .trim()
    .min(10, "もう少し詳しくお聞かせください(最低10文字)")
    .max(2000),
  consent: z.boolean().refine((v) => v, "続行するには同意してください"),
});
export type ContactInput = z.infer<typeof contactSchema>;
// src/components/ContactForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { contactSchema, type ContactInput } from "@/lib/schemas/contact";

export function ContactForm() {
  const form = useForm<ContactInput>({
    resolver: zodResolver(contactSchema),
    mode: "onTouched",
    defaultValues: {
      name: "",
      email: "",
      phone: "",
      message: "",
      consent: false,
    },
  });

  async function onSubmit(values: ContactInput) {
    const res = await fetch("/api/contact", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(values),
    });
    if (res.status === 422) {
      const { fieldErrors } = (await res.json()) as {
        fieldErrors: Partial<Record<keyof ContactInput, string>>;
      };
      for (const [field, message] of Object.entries(fieldErrors)) {
        form.setError(field as keyof ContactInput, { type: "server", message });
      }
      return;
    }
    if (!res.ok) {
      form.setError("root", {
        message:
          "メッセージを送信できませんでした。接続を確認して、もう一度お試しください。",
      });
      return;
    }
    toast.success("メッセージを送信しました。1営業日以内にご連絡いたします。");
    form.reset();
  }

  const { isSubmitting, errors } = form.formState;

  return (
    <Form {...form}>
      <form
        onSubmit={form.handleSubmit(onSubmit)}
        noValidate
        className="grid gap-5 sm:grid-cols-2"
      >
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>氏名</FormLabel>
              <FormControl>
                <Input autoComplete="name" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>メールアドレス</FormLabel>
              <FormControl>
                <Input type="email" autoComplete="email" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="topic"
          render={({ field }) => (
            <FormItem>
              <FormLabel>トピック</FormLabel>
              <Select onValueChange={field.onChange} value={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="トピックを選択" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="booking">予約</SelectItem>
                  <SelectItem value="billing">請求</SelectItem>
                  <SelectItem value="other">その他</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="phone"
          render={({ field }) => (
            <FormItem>
              <FormLabel>電話番号(任意)</FormLabel>
              <FormControl>
                <Input
                  type="tel"
                  inputMode="tel"
                  autoComplete="tel"
                  {...field}
                />
              </FormControl>
              <FormDescription>折り返し電話をご希望の場合のみご記入ください。</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="message"
          render={({ field }) => (
            <FormItem className="sm:col-span-2">
              <FormLabel>メッセージ</FormLabel>
              <FormControl>
                <Textarea rows={5} {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="consent"
          render={({ field }) => (
            <FormItem className="flex items-start gap-3 sm:col-span-2">
              <FormControl>
                <Checkbox
                  checked={field.value}
                  onCheckedChange={field.onChange}
                />
              </FormControl>
              <div className="space-y-1 leading-none">
                <FormLabel>
                  このリクエストに関する連絡を受けることに同意します
                </FormLabel>
                <FormMessage />
              </div>
            </FormItem>
          )}
        />
        {errors.root && (
          <p role="alert" className="text-sm text-destructive sm:col-span-2">
            {errors.root.message}
          </p>
        )}
        <div className="sm:col-span-2">
          <Button
            type="submit"
            disabled={isSubmitting}
            className="w-full sm:w-auto"
          >
            {isSubmitting && (
              <Loader2
                className="mr-2 h-4 w-4 animate-spin"
                aria-hidden="true"
              />
            )}
            {isSubmitting ? "送信中..." : "メッセージを送信"}
          </Button>
        </div>
      </form>
    </Form>
  );
}

注意点:

  • noValidate によりネイティブブラウザのバリデーションバブルを無効にし、zod メッセージのみを表示します。
  • 常に defaultValues(空文字列、undefined ではない)を渡し、初回レンダリングから入力が制御されるようにします。
  • Select/RadioGroup では value={field.value} を使用し(defaultValue ではない)、form.reset() でクリアされるようにします。
  • TanStack Query のミューテーションを介してフォームを送信する場合は、isSubmitting の代わりに mutation.isPending を使用し、onSubmit 内で mutateAsync を呼び出して react-hook-form がそれを待機できるようにします。

サーバーサイドバリデーション

同じスキーマでパースし、フィールドエラーとともに 422 を返します:

const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
  return Response.json(
    { fieldErrors: parsed.error.flatten().fieldErrors },
    { status: 422 },
  );
}

flatten().fieldErrors はフィールドごとに string[] を返します。最初のメッセージ(fieldErrors.email?.[0])を送信するか、クライアントのループを適応させてください。データベース制約(一意のメールアドレス、外部キー)もフィールドエラーにマッピングします: 一意制約違反をキャッチし、{ fieldErrors: { email: "このメールアドレスは既に登録されています" } } を返します。RLS のみのアクセスを持つ Supabase アプリでは、データベース制約またはエッジファンクションで再度バリデーションを行ってください。クライアントに依存しないでください。

エラーメッセージのスタイル

  • 何が問題かではなく、何をすべきかを伝える: 「今日より後の日付を入力してください」であり、「無効な日付」ではありません。
  • ユーザーの用語を使用し、期待される形式をインラインで表示する("DD/MM/YYYY")。
  • 一度にフィールドごとに1つのメッセージ。ラベルを繰り返さない。
  • アプリの言語でメッセージを記述。zod メッセージはプレーンな文字列なので、翻訳テキストを渡します(thai-localization スキルを参照)。

状態チェックリスト

  • 空: ラベル、役立つプレースホルダー(例: 指示ではなく例)。
  • 入力中: 最初のブラーまでエラーなし。
  • 無効: 赤い枠線 + フィールド下のメッセージ(FormMessage)、送信時に最初のエラーにフォーカス移動。
  • 送信中: スピナーと進捗ラベル付きでボタン無効。入力は読み取り可能のまま。
  • サーバーエラー: 送信ボタンの上に role="alert" 付きメッセージ。入力は保持。
  • 成功: トーストまたはインライン確認。フォームリセットまたはリダイレクト。
  • 長いフォームでの未保存の変更: ナビゲーション前に警告(formState.isDirty)。

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

関連スキル