فرم با اعتبارسنجی
اعتبارسنجی فیلدها و خطای ذخیره در FormPage و FormRow با React Hook Form، aria-invalid و اعلام خطا
معرفی
فرم رایجترین نقطه ورود داده کاربر به محصول است و اعتبارسنجی، حساسترین لحظه آن: اگر خطا دیر، مبهم، یا فقط بهصورت بصری نمایش داده شود، کاربر یا داده اشتباه ثبت میکند یا فرم را رها میکند — و کاربر صفحهخوان اصلاً متوجه خطا نمیشود. در پرتو صفحهای که یک فرم است FormPage است و هر فیلد یک FormRow: state فرم با react-hook-form است و برچسب، ستارهٔ فیلد اجباری، توضیح، پیام خطا و سیمکشی دسترسپذیری هر فیلد با FormRow. صفحه فقط قاعدهها (هر کدام با پیام خودش) و ذخیره را مینویسد.
این الگو نشان میدهد:
- خطای هر فیلد زیر همان فیلد میآید و اعلام میشود —
FormRowبه کنترلaria-invalidوaria-describedbyمیدهد و پیام را باrole="alert"نشان میدهد، بدون سیمکشی دستی - هر قاعده پیام خودش را دارد و قالب ایمیل را
patternبررسی میکند (فرم قالب اعتبارسنجی مرورگر را خاموش میکند) - خطای سرور دربارهٔ یک فیلد روی همان فیلد مینشیند (
setError('email', …)) و خطای کلی ذخیره،errorخودFormPageاست (setError('root', …)) — خلاصهای بالای فرم که اعلام و فوکوس میشود - دکمهٔ ثبت و «انصراف» را قالب میسازد (
submitLabel،submitting،onCancel)؛ ذخیرهٔ موفق فقط یکtoast.successاست
چه زمانی از این الگو استفاده کنیم:
- صفحهٔ ثبت یا ویرایش داده با چند فیلد قاعدهدار که یکبار ذخیره میشود (نمایه، منبع تازه، دعوت عضو)
- وقتی داده به سرور ارسال میشود و خطای سرور باید روی همان فیلد مربوطه (نه فقط یک پیام کلی) نمایش داده شود
چه زمانی استفاده نکنیم (جایگزینها):
- جستوجو یا فیلتر یک فهرست —
searchوfiltersقالبListPage؛ اعتبارسنجی ندارد - تنظیماتی در چند گروه که هر گروه جدا ذخیره میشود —
SettingsPageوSettingsSection(همینFormRowها و همینerror) - فرم کوتاه در یک دیالوگ — همین
FormRowها در یکFormSectionداخل<form id>دیالوگ (FormRow) - ساختار کلی فرم (گروهبندی فیلدها، فرم چندمرحلهای، لحن پیامها) — راهنمای الگوهای فرم
نمونه بصری
فرم «تنظیمات هشدار»: ثبت با فیلدهای خالی پیام هر فیلد را زیر همان فیلد نشان میدهد و فوکوس به اولین فیلد نامعتبر میرود:
پیادهسازی
صفحهٔ نمایه با دو فیلد. app/profile/page.tsx یک Server Component است که <ProfileScreen /> را بدون ویژگی رندر میکند؛ ذخیره در خود صفحهٔ کلاینت است:
'use client'
import { useForm } from 'react-hook-form'
import { Input, toast } from '@partodata/ui'
import { Form, FormField } from '@partodata/ui/form'
import { FormPage, FormRow } from '@partodata/ui/templates'
type Profile = { name: string; email: string }
/** A save the server refused, with the field it is about (a taken email) or none (anything else). */
class SaveError extends Error {
field?: keyof Profile
constructor(message: string, field?: keyof Profile) {
super(message)
this.field = field
}
}
// The app's save request, at module level.
async function saveProfile(values: Profile): Promise<Profile> {
const response = await fetch('/api/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
})
if (response.status === 409) throw new SaveError('این نشانی ایمیل برای حساب دیگری ثبت شده است', 'email')
if (!response.ok) throw new SaveError('ذخیره انجام نشد؛ لحظهای بعد دوباره تلاش کنید.')
return response.json()
}
// «نمایهٔ من» has its own menu item: no `back`, and «انصراف» discards the changes.
export function ProfileScreen() {
const form = useForm<Profile>({
defaultValues: { name: '', email: '' },
mode: 'onTouched', // first validation on blur, then live re-validation
})
const save = form.handleSubmit(async (values) => {
try {
form.reset(await saveProfile(values))
toast.success('نمایه ذخیره شد')
} catch (error) {
// A field the server refused shows its error on that field; any other failure is the form's root error,
// which FormPage shows above the form.
if (error instanceof SaveError && error.field) {
form.setError(error.field, { type: 'server', message: error.message }, { shouldFocus: true })
} else {
form.setError('root', { type: 'server', message: 'ذخیره انجام نشد؛ لحظهای بعد دوباره تلاش کنید.' })
}
}
})
return (
<Form {...form}>
<FormPage
title="نمایهٔ من"
onSubmit={save}
onCancel={() => form.reset()}
submitLabel="ذخیره"
submitting={form.formState.isSubmitting}
error={form.formState.errors.root?.message}
>
<FormField
control={form.control}
name="name"
rules={{ required: 'نام نمایشی را وارد کنید' }}
render={({ field, fieldState }) => (
<FormRow label="نام نمایشی" required error={fieldState.error?.message}>
<Input placeholder="سارا رضایی" {...field} />
</FormRow>
)}
/>
<FormField
control={form.control}
name="email"
rules={{
required: 'نشانی ایمیل را وارد کنید',
pattern: { value: /^\S+@\S+\.\S+$/, message: 'نشانی ایمیل درست نیست' },
}}
render={({ field, fieldState }) => (
<FormRow
label="نشانی ایمیل"
required
description="گزارشهای هفتگی به این نشانی فرستاده میشود."
error={fieldState.error?.message}
>
{/* email addresses are LTR content, even inside an RTL form */}
<Input kind="email" {...field} />
</FormRow>
)}
/>
</FormPage>
</Form>
)
}آنچه قالب و FormRow خودشان انجام میدهند (نیازی به کدنویسی مجدد نیست):
FormRowبرچسب را به کنترل وصل میکند (aria-labelledby؛ کلیک روی برچسب فوکوس را به کنترل میبرد)، برای ردیفrequiredستارهای (پنهان از صفحهخوان) کنار برچسب وaria-requiredروی کنترل میگذارد، و هنگام خطا به کنترلaria-invalidوaria-describedby(توضیح + پیام خطا) میدهد؛ پیام خطا باrole="alert"اعلام میشودFormPageفرم را باnoValidateمیسازد، دکمهٔ ثبت را در حینsubmittingقفل میکند، وerrorرا بالای فرم نشان میدهد، اعلام میکند و فوکوس را به آن میبرد؛ ارسالی که اعتبارسنجی فیلدها متوقفش کند فوکوس را روی اولین فیلد نامعتبر میگذارد- قاعدهٔ بیپیام (
required: true) فقط یک پیام عمومی نشان میدهد و در حالت توسعه هشدار میدهد
الگوهای رایج
اعتبارسنجی با Zod
برای فرمهایی که قواعدشان بین چند فرم یا بین کلاینت و سرور مشترک است، بهجای rules روی تکتک فیلدها، یک schema با Zod تعریف کنید. تایپ فرم هم از همان schema استخراج میشود؛ پیام هر قاعدهٔ schema همان fieldState.error?.message است که به error هر FormRow میرسد:
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
const profileSchema = z.object({
name: z.string().min(2, 'نام نمایشی باید دستکم 2 نویسه باشد'),
email: z.string().email('نشانی ایمیل درست نیست'),
})
type Profile = z.infer<typeof profileSchema>
const form = useForm<Profile>({
resolver: zodResolver(profileSchema),
mode: 'onTouched',
defaultValues: { name: '', email: '' },
})با schema، rules از FormFieldها حذف میشود و required روی FormRow میماند (ستاره و aria-required).
خطای سرور: روی فیلد یا بالای فرم
اعتبارسنجی کلاینت جای اعتبارسنجی سرور را نمیگیرد. دو حالت، هر کدام یک جا:
- خطایی دربارهٔ یک فیلد (مثلاً «این نشانی ایمیل برای حساب دیگری ثبت شده است»):
form.setError('email', { message })— در همانFormRowو با همان سیمکشی دسترسپذیری نمایش داده میشود - خطای کلی ذخیره (شبکه، خطای 500):
form.setError('root', { message })وerror={form.formState.errors.root?.message}رویFormPage— خلاصهای بالای فرم که قالب میسازد؛ ارسال بعدی آن را پاک میکند.Alertیا متن خطای خودتان را کنار فرم نگذارید
متن خام خطای فنی سرور (پیام exception یا کد وضعیت) را به کاربر نشان ندهید؛ آن را به یک جملهٔ قابل فهم ترجمه کنید (مانند SaveError بالا).
زمانبندی اعتبارسنجی (mode)
زمان نمایش اولین خطا، تجربه فرم را تعیین میکند:
onSubmit(پیشفرض react-hook-form) — خطاها فقط پس از تلاش برای ارسال ظاهر میشوند؛ برای فرمهای کوتاه قابل قبول استonTouched(توصیهشده) — اولین اعتبارسنجی هنگام ترک فیلد (blur) و پس از آن بهصورت زنده؛ کاربر وسط تایپ اولیه سرزنش نمیشود، اما پس از اصلاح، خطا فوراً پاک میشودonChange— از اولین کاراکتر خطا میدهد؛ برای بیشتر فرمها مزاحم است و فقط برای فیلدهای وابستهای مثل «تکرار رمز عبور» منطقی است
دسترسیپذیری
رفتارهای زیر در FormRow و FormPage پیاده شدهاند و با استفاده از الگو خودکار برقرارند:
- کنترل خطادار
aria-invalid="true"وaria-describedby(توضیح + پیام خطا) دارد، پس صفحهخوان هنگام فوکوس روی فیلد، پیام خطا را میخواند؛ پیام باrole="alert"در لحظهٔ وقوع هم اعلام میشود - برچسب با
aria-labelledbyبه کنترل متصل است؛ کلیک روی برچسب فوکوس را به فیلد میبرد - فیلد اجباری با
requiredرویFormRowمشخص میشود — ستارهٔ بصری (پنهان از صفحهخوان) وaria-requiredروی کنترل را ردیف خودش میگذارد؛ ستاره را دستی نسازید - تعامل با کیبورد: فشردن
Enterداخل هر فیلد فرم را ارسال میکند (رفتار بومی<form>کهFormPageمیسازد)؛ دکمهٔ ثبت را قالب باtype="submit"میسازد
بهترین روشها و دامهای رایج
بکنید
- هر فیلد را یک
FormRowدرFormPageبنویسید و پیامش را ازfieldState.error?.messageبهerrorبدهید - به هر قاعده پیام مشخص، قابل اقدام و با فارسی رسمی بدهید — «نشانی ایمیل درست نیست»، نه «ایمیلت اشتباهه» - ازmode: 'onTouched'استفاده کنید تا اولین خطا هنگام ترک فیلد ظاهر شود و پس از اصلاح فوراً پاک شود - خطای سرور دربارهٔ یک فیلد را باsetErrorروی همان فیلد بنشانید و خطای کلی ذخیره را باsetError('root', …)بهerrorقالب بدهید -submitting={form.formState.isSubmitting}را به قالب بدهید تا دکمهٔ ثبت در حین ذخیره قفل شود
نکنید
- دکمهٔ ثبت خودتان را ننویسید:
FormPageآن را باsubmitLabelمیسازد - خطای ذخیره را باCalloutیا متن خودتان بالای فرم نگذارید:errorقالب همان خلاصه را میسازد، اعلام و فوکوس میکند - دکمهٔ ثبت را بهخاطر نامعتبر بودن فرمdisabledنکنید — کاربر نمیفهمد چه چیزی مانع ارسال است - از placeholder بهجای برچسب استفاده نکنید — با شروع تایپ ناپدید میشود و صفحهخوانها آن را برچسب حساب نمیکنند
دامهای رایج
فیلد ساختهشده از اجزای سطح پایین
اشتباه: ساختن فیلد صفحه از FormItem، FormLabel، FormControl و FormMessage، با ستاره و پیام دستی.
چرا مشکلساز است: همان کاری که FormRow میکند دوباره و هر بار کمی متفاوت نوشته میشود: جای برچسب در FormPage و SettingsSection، اندازهٔ کنترل، ستاره و aria-required و سیمکشی خطا از صفحهای به صفحهٔ دیگر فرق میکند. قانون ESLint parto/form-row این اجزا را گزارش میکند.
الگوی درست: FormField با render={({ field, fieldState }) => <FormRow label required error={fieldState.error?.message}>…</FormRow>}.
وارد کردن Form از barrel اصلی
اشتباه: import { Form, FormField } from '@partodata/ui'
چرا مشکلساز است: Form عمداً از barrel اصلی صادر نمیشود (سورس form.tsx به useFormState وابسته است که در حالت react-server از react-hook-form حذف میشود و RSC-analysis مصرفکننده را میشکند). نتیجه وارد کردن از barrel، کامپوننت undefined و خطای «Element type is invalid» در زمان اجرا است.
الگوی درست: import { Form, FormField } from '@partodata/ui/form'، FormPage و FormRow از @partodata/ui/templates، و بقیه (مثل Input) از barrel اصلی.
رنگ هاردکد برای حالت خطا
اشتباه: استایلدادن پیام یا حاشیه خطا با رنگ ثابت:
// ❌ فقط در یک تم درست دیده میشود و lint را رد نمیکند
<p className="text-red-500">نشانی ایمیل درست نیست</p>
// ✅ پیام را به `error` ردیف بدهید؛ FormRow آن را با توکن destructive نشان میدهد
<FormRow label="نشانی ایمیل" required error={fieldState.error?.message}>
<Input kind="email" {...field} />
</FormRow>چرا مشکلساز است: این سیستم طراحی dark-first است (تم پایه :root تیره است) و همه رنگها باید از توکنها بیایند؛ text-red-500 در یکی از دو تم شکسته دیده میشود و قانون ESLint no-hardcoded-colors آن را رد میکند.
الگوی درست: پیام را به error ردیف بدهید؛ FormRow آن را با رنگ توکن destructive نشان میدهد. هر استایل سفارشی خطا هم فقط با توکنهای --destructive.
اعتبارسنجی یکتایی سمت کلاینت روی داده صفحهبندیشده سروری
اشتباه: یکتایی نام را با جستجو در ردیفهای بارگذاریشدهٔ یک فهرست صفحهبندیشده بررسی کنید: rows.some((r) => r.name === value).
چرا مشکلساز است: در فهرست سروری فقط ردیفهای صفحهٔ فعلی در کلاینت موجودند؛ نام تکراری که در صفحهٔ دیگری است از اعتبارسنجی رد میشود و کاربر بهجای خطای فیلد، با خطای مبهم سرور هنگام ثبت مواجه میشود.
الگوی درست: یکتایی را سمت سرور بررسی کنید و پاسخ خطا (مثلاً 409) را با setError('name', { message: 'منبعی با این نام از قبل وجود دارد' }) روی همان فیلد بنشانید — همان الگوی «خطای سرور» بالا.
اجزای سطح پایینتر: فیلدی بیرون از قالبها
FormItem، FormLabel، FormControl، FormDescription و FormMessage (از @partodata/ui/form) اجزایی هستند که FormRow روی آنها ساخته شده است. فیلد صفحه، دیالوگ و SettingsSection همیشه FormRow است؛ این اجزا فقط برای فیلدی بیرون از قالبها و دیالوگها (مثلاً یک ویرایشگر درونخطی در CustomPage) هستند و همان سیمکشی را دارند:
<FormField
control={form.control}
name="name"
rules={{ required: 'نام نمایشی را وارد کنید' }}
render={({ field }) => (
<FormItem>
<FormLabel>نام نمایشی</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>FormItem برای هر فیلد یک id یکتا میسازد و آن را به برچسب، توضیح و پیام خطا گره میزند؛ FormControl روی خود کنترل aria-invalid و aria-describedby را تنظیم میکند — داخل آن aria-* یا id دستی ندهید. مرجع کامل: فرم (Form).
صفحات مرتبط
- الگوهای فرم — اگر پرسش شما فراتر از اعتبارسنجی است (گروهبندی فیلدها، فرم چندمرحلهای، ساختار کلی)، اول آن راهنما را ببینید.
- FormPage — قالب صفحهٔ فرم: کارت، «انصراف» و دکمهٔ ثبت، و خلاصهٔ خطای ذخیره (
error). - FormRow — یک فیلد: جدول کنترل هر نوع فیلد و سیمکشی آن به
field. - اشتباهات رایج — فهرست کامل ضدالگوهای سراسری (رنگ hardcode، خواص فیزیکی CSS و …)؛ دامهای این صفحه نمونههای همان ریشهها در این الگو هستند.
- الگوهای خطا — خطاهای سطح صفحه و API (بیرون از فرم): حالت
stateقالبها وUtilityPage.