فرمهای Type-safe در React با React Hook Form و Zod
فرمها معمولاً ساده شروع میشوند: چند input، یک submit و چند شرط ساده. اما با رشد پروژه، validationهای پراکنده، typeهای تکراری، خطاهای API، fieldهای شرطی و آرایههای داینامیک بهسرعت فرم را سختنگهداری میکنند.
الگویی که برای پروژههای React قابلاعتمادتر است این است که schema منبع اصلی حقیقت باشد، React Hook Form مدیریت state و interaction را انجام دهد و Zod مسئول runtime validation و مدل داده باشد.
چرا schema-first؟
اگر type و validation جدا تعریف شوند، احتمال drift بالا میرود:
type ProfileForm = {
name: string
age: number
}
بعد ممکن است validation جای دیگری تغییر کند و type همان قبلی بماند. با Zod میتوان schema و type را به هم متصل کرد:
import { z } from "zod"
const profileSchema = z.object({
name: z.string().trim().min(2),
age: z.coerce.number().int().min(18),
})
type ProfileForm = z.infer<typeof profileSchema>
Zod علاوه بر z.infer، وقتی transform یا coercion باعث تفاوت ورودی و خروجی میشود، z.input و z.output هم دارد.
اتصال به React Hook Form
Resolver اجازه میدهد Zod مستقیماً در چرخه React Hook Form استفاده شود:
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
const form = useForm<ProfileForm>({
resolver: zodResolver(profileSchema),
defaultValues: {
name: "",
age: 18,
},
})
در فرمهای ساده این الگو کافی است. اگر schema داده را transform میکند، بهتر است تفاوت input و output را آگاهانه مدیریت کنیم.
خطاها را نزدیک همان فیلد نگه دارید
<input
{...form.register("name")}
aria-invalid={Boolean(form.formState.errors.name)}
/>
{form.formState.errors.name?.message && (
<p role="alert">{form.formState.errors.name.message}</p>
)}
خطای مربوط به یک فیلد بهتر است نزدیک همان فیلد نمایش داده شود. این کار هم UX را بهتر میکند و هم برای accessibility قابلفهمتر است.
validation سمت کلاینت مرز امنیت نیست
قاعده مهم:
Client validation = UX
Server validation = Trust boundary
حتی اگر Zod در فرانتاند تمام ورودیها را validate کند، کاربر میتواند request را خارج از UI ارسال کند. backend باید داده را مستقل بررسی کند.
خطاهای API را به فرم برگردانید
بعضی خطاها در schema قابل تشخیص نیستند؛ مثلاً ایمیلی که قبلاً ثبت شده است:
form.setError("email", {
type: "server",
message: "این ایمیل قبلاً ثبت شده است",
})
خطاهای field-level را با setError به همان فیلد متصل کنید و خطاهای عمومی را در سطح فرم نگه دارید.
Field Array
برای لیستهای داینامیک مثل شماره تماس:
const schema = z.object({
phones: z.array(
z.object({
label: z.string().min(1),
value: z.string().min(7),
})
).min(1),
})
و در React Hook Form:
const phones = useFieldArray({
control: form.control,
name: "phones",
})
یک اشتباه رایج این است که همان آرایه هم در React Hook Form و هم در useState نگهداری شود. دو منبع state معمولاً synchronization را سختتر میکنند.
فیلدهای شرطی را در schema مدل کنید
برای فرمهایی که shape آنها بر اساس نوع حساب تغییر میکند، discriminated union خواناتر است:
const schema = z.discriminatedUnion("accountType", [
z.object({
accountType: z.literal("personal"),
fullName: z.string().min(2),
}),
z.object({
accountType: z.literal("company"),
companyName: z.string().min(2),
taxId: z.string().min(5),
}),
])
async validation را محدود کنید
بررسی uniqueness یا invitation code ممکن است به شبکه نیاز داشته باشد. بهتر است چنین validationهایی را روی هر keystroke اجرا نکنیم.
الگوی مناسبتر:
- validation ساختاری در کلاینت؛
- async check هدفمند در صورت نیاز UX؛
- validation نهایی سمت سرور هنگام submit.
اگر schema refinement یا transform async دارد، باید از parse async استفاده شود.
ساختار پیشنهادی
features/profile-form/
├── profile.schema.ts
├── profile-form.tsx
├── profile-form.api.ts
└── profile-form.test.tsx
هدف جداسازی مسئولیتهاست، نه زیاد کردن بیدلیل فایلها.
چه چیزهایی را تست کنیم؟
بهجای implementation detail، رفتار فرم را تست کنید:
- داده نامعتبر submit نشود؛
- پیام خطا نمایش داده شود؛
- داده معتبر به handler برسد؛
- خطای API روی فیلد درست map شود؛
- field array درست اضافه و حذف شود.
جمعبندی
ترکیب React Hook Form و Zod زمانی ارزش واقعی دارد که یک قرارداد معماری بسازد:
Zod Schema
↓
Types + Runtime Validation
↓
React Hook Form
↓
User Interaction
↓
API
↓
Server Validation
وقتی schema منبع حقیقت باشد و مرز validation کلاینت/سرور روشن بماند، فرمهای بزرگ قابلپیشبینیتر و refactor آنها سادهتر میشود.