فرم با اعتبارسنجی

اعتبارسنجی فیلدها و خطای ذخیره در 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.