フォームとバリデーション は何をしますか?
スタック: react-hook-form + zod + @hookform/resolvers/zod + shadcn/ui
Form コンポーネント(KODRA スキャフォールドに既にインストール済み)。1つの zod スキーマが型、クライアント側バリデーション、サーバー側バリデーションの唯一の真実源です。
ルール
- スキーマファースト。zod スキーマを定義し、
z.inferで TypeScript 型を導出します。共有スキーマはsrc/lib/schemas/<name>.tsに配置し、サーバールートが同じファイルをインポートできるようにします。 - サーバー側でもバリデーションする。クライアントバリデーションは UX のためです。サーバールート、エッジファンクション、またはデータベース制約がセキュリティ境界です。同じスキーマでボディを再パースし、フィールドエラーを返します。
- すべてのフィールドに表示可能なラベル、形式が明確でない場合の役立つ説明、フィールドの下にインラインエラーを表示します。
- バリデーションのタイミング:
mode: "onTouched"(最初のブラー後にバリデーション、その後は変更時)。初回入力中にユーザーを驚かせないようにします。 - 送信ボタンは送信開始まで有効のままにし、その後は送信待機状態を表示して無効にし、二重送信を防止します。
- 成功時は何が起こったかを表示し(トーストまたはインラインメッセージ)、リセットまたはナビゲートします。失敗時はユーザーの入力を保持し、フォームの近くにエラーを表示し、最初の無効なフィールドにフォーカスを移動します(react-hook-form がデフォルトで行います)。
- 適切な入力タイプを使用:
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)。