پرتوپرتو

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

الگوی کامل فرم با React Hook Form، نمایش خطاها، و aria-invalid

معرفی

فرم رایج‌ترین نقطه ورود داده کاربر به محصول است و اعتبارسنجی، حساس‌ترین لحظه آن: اگر خطا دیر، مبهم، یا فقط به‌صورت بصری نمایش داده شود، کاربر یا داده اشتباه ثبت می‌کند یا فرم را رها می‌کند — و کاربر صفحه‌خوان اصلاً متوجه خطا نمی‌شود. الگوی «فرم با اعتبارسنجی» state فرم را به react-hook-form می‌سپارد و نمایش برچسب، توضیح، و پیام خطا را به کامپوننت‌های Form* دیزاین‌سیستم، تا هر فیلد به‌صورت خودکار سیم‌کشی دسترس‌پذیری درست داشته باشد.

این الگو نشان می‌دهد چگونه یک فرم با اعتبارسنجی کامل بسازید که:

  • خطاهای فیلدها را با aria-invalid و aria-describedby نمایش می‌دهد — به‌صورت خودکار از طریق FormControl، بدون سیم‌کشی دستی
  • با react-hook-form کار می‌کند و state اعتبارسنجی یک منبع واحد دارد
  • برای کاربران صفحه‌خوان قابل دسترس است

چه زمانی از این الگو استفاده کنیم:

  • فرم‌های ثبت و ویرایش داده با چند فیلد قاعده‌دار (تنظیمات کمپین، پروفایل کاربر، دعوت اعضای تیم)
  • وقتی داده به سرور ارسال می‌شود و خطای سرور باید روی همان فیلد مربوطه (نه فقط یک پیام کلی) نمایش داده شود
  • وقتی حالت‌های فرم (خطاها، isSubmitting، مقادیر پیش‌فرض) باید از یک منبع واحد مدیریت شوند

چه زمانی استفاده نکنیم (جایگزین‌ها):

  • یک فیلد ساده بدون قاعده اعتبارسنجی (جستجو، فیلتر) — Input یا SearchInput ساده کافی است؛ داربست react-hook-form فقط پیچیدگی اضافه می‌کند
  • فقط چیدمان برچسب/توضیح/خطا را می‌خواهید و state را خودتان مدیریت می‌کنید — از خانواده Field یا FormItemLayout (با prop صریح error) استفاده کنید
  • دنبال ساختار کلی فرم هستید (چیدمان، فرم چندمرحله‌ای، لحن پیام‌ها) — ابتدا راهنمای الگوهای فرم را ببینید

نمونه بصری

آناتومی یک فرم اعتبارسنجی‌شده — برچسب بالای فیلد، توضیح کمکی، و پیام خطا زیر فیلد مربوطه:

نام کاربری شما در پنل نمایش داده می‌شود.

پیاده‌سازی

کامپوننت‌های Form* عمداً از barrel اصلی صادر نمی‌شوند و باید از زیرمسیر @partodata/ui/form وارد شوند (دلیل: سازگاری RSC — جزئیات در دام شماره ۲ پایین صفحه). نکته کلیدی این پیاده‌سازی این است که هیچ aria-* یا id دستی ندارد؛ FormControl همه را خودش تنظیم می‌کند.

'use client'

import { useForm } from 'react-hook-form'
// Form ships via its own subpath entry (not the main barrel) for RSC compatibility.
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@partodata/ui/form'
import { Input, Button } from '@partodata/ui'

interface ProfileForm {
  name: string
  email: string
}

export function ProfileFormExample() {
  const form = useForm<ProfileForm>({
    mode: 'onTouched', // first validation on blur, then live re-validation
    defaultValues: { name: '', email: '' },
  })

  function onSubmit(data: ProfileForm) {
    console.log(data)
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="name"
          rules={{ required: 'نام الزامی است' }}
          render={({ field }) => (
            <FormItem>
              <FormLabel>نام</FormLabel>
              <FormControl>
                <Input {...field} placeholder="نام خود را وارد کنید" />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="email"
          rules={{
            required: 'ایمیل الزامی است',
            pattern: {
              value: /^[^@]+@[^@]+\.[^@]+$/,
              message: 'فرمت ایمیل صحیح نیست',
            },
          }}
          render={({ field }) => (
            <FormItem>
              <FormLabel>ایمیل</FormLabel>
              {/* email addresses are LTR content, even inside an RTL form */}
              <FormControl>
                <Input {...field} type="email" dir="ltr" placeholder="example@domain.com" />
              </FormControl>
              <FormDescription>گزارش‌های هفتگی کمپین به این آدرس ارسال می‌شود.</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button htmlType="submit" variant="primary">
          ذخیره
        </Button>
      </form>
    </Form>
  )
}

رفتارهایی که این کامپوننت‌ها به‌صورت داخلی مدیریت می‌کنند (نیازی به کدنویسی مجدد نیست):

  • FormItem برای هر فیلد یک id یکتا (با React.useId) می‌سازد و آن را به برچسب، توضیح، و پیام خطا گره می‌زند
  • FormControl روی خود کنترل aria-invalid را با وضعیت خطای react-hook-form همگام می‌کند و aria-describedby را طوری تنظیم می‌کند که همیشه به توضیح فیلد و — فقط هنگام خطا — به پیام خطا هم اشاره کند
  • FormLabel با htmlFor به کنترل متصل می‌شود و هنگام خطا (از طریق data-error) به رنگ توکن destructive درمی‌آید
  • FormMessage متن error.message را از react-hook-form می‌خواند، بدون خطا اصلاً رندر نمی‌شود، و با انیمیشن کوتاه (۱۵۰ میلی‌ثانیه) ظاهر می‌شود

الگوهای رایج

اعتبارسنجی با Zod

برای فرم‌هایی که قواعدشان بین چند فرم یا بین کلاینت و سرور مشترک است، به‌جای rules روی تک‌تک فیلدها، یک schema با Zod تعریف کنید. تایپ فرم هم از همان schema استخراج می‌شود و دیگر جداگانه نگهداری نمی‌شود.

import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'

const profileSchema = z.object({
  name: z.string().min(2, 'نام باید حداقل ۲ کاراکتر باشد'),
  email: z.string().email('آدرس ایمیل نامعتبر است'),
})

type ProfileForm = z.infer<typeof profileSchema>

const form = useForm<ProfileForm>({
  resolver: zodResolver(profileSchema),
  mode: 'onTouched',
  defaultValues: { name: '', email: '' },
})

نمایش خطای سرور روی فیلد مربوطه

اعتبارسنجی کلاینت جای اعتبارسنجی سرور را نمی‌گیرد. خطای فیلددار سرور (مثلاً «این ایمیل قبلاً ثبت شده است») را با setError روی همان فیلد بنشانید تا در همان FormMessage و با همان سیم‌کشی دسترس‌پذیری نمایش داده شود؛ خطای کلی را روی root بگذارید و بالای فرم با Alert نشان دهید.

import { Alert, AlertDescription, AlertTitle, toast } from '@partodata/ui'

type ServerFieldError = { field?: 'name' | 'email'; message: string }

async function onSubmit(data: ProfileForm) {
  const res = await fetch('/api/profile', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  })
  if (!res.ok) {
    const err: ServerFieldError = await res.json()
    if (err.field) {
      form.setError(err.field, { type: 'server', message: err.message })
    } else {
      form.setError('root', { type: 'server', message: err.message })
    }
    return
  }
  toast.success('تغییرات ذخیره شد')
}
{/* بالای فرم، پیش از اولین فیلد */}
{form.formState.errors.root && (
  <Alert variant="destructive">
    <AlertTitle>ذخیره انجام نشد</AlertTitle>
    <AlertDescription>{form.formState.errors.root.message}</AlertDescription>
  </Alert>
)}

برای جلوگیری از ارسال مجدد در حین درخواست، حالت بارگذاری دکمه را به isSubmitting گره بزنید:

<Button htmlType="submit" variant="primary" isLoading={form.formState.isSubmitting}>
  ذخیره
</Button>

زمان‌بندی اعتبارسنجی (mode)

زمان نمایش اولین خطا، تجربه فرم را تعیین می‌کند:

  • onSubmit (پیش‌فرض react-hook-form) — خطاها فقط پس از تلاش برای ارسال ظاهر می‌شوند؛ برای فرم‌های کوتاه قابل قبول است
  • onTouched (توصیه‌شده) — اولین اعتبارسنجی هنگام ترک فیلد (blur) و پس از آن به‌صورت زنده؛ کاربر وسط تایپ اولیه سرزنش نمی‌شود، اما پس از اصلاح، خطا فوراً پاک می‌شود
  • onChange — از اولین کاراکتر خطا می‌دهد؛ برای بیشتر فرم‌ها مزاحم است و فقط برای فیلدهای وابسته‌ای مثل «تکرار رمز عبور» منطقی است

دسترسی‌پذیری

رفتارهای زیر مستقیماً در سورس form.tsx پیاده شده‌اند و با استفاده درست از الگو به‌صورت خودکار برقرارند:

  • FormControl مقدار aria-invalid را از وضعیت خطای react-hook-form می‌گیرد و aria-describedby را هنگام خطا به «توضیح + پیام خطا» و در حالت عادی فقط به «توضیح» اشاره می‌دهد — بنابراین صفحه‌خوان هنگام فوکوس روی فیلد خطادار، پیام خطا را می‌خواند
  • FormLabel با htmlFor به id تولیدشده کنترل متصل است؛ کلیک روی برچسب فوکوس را به فیلد می‌برد
  • FormMessage به‌خودی‌خود aria-live ندارد؛ اگر لازم است خطا در لحظه وقوع (نه فقط هنگام فوکوس روی فیلد) اعلام شود، آن را در یک live region قرار دهید:
<div role="alert" aria-live="polite">
  <FormMessage />
</div>
  • فیلدهای اجباری را هم بصری و هم برای صفحه‌خوان مشخص کنید:
<FormLabel>
  ایمیل
  <span aria-hidden="true" className="text-destructive ms-1">*</span>
  <span className="sr-only"> (اجباری)</span>
</FormLabel>
  • تعامل با کیبورد: فشردن Enter داخل هر فیلد، فرم را ارسال می‌کند (رفتار بومی <form>) — به همین دلیل دکمه ارسال باید htmlType="submit" باشد، نه یک دکمه معمولی با onClick

بهترین روش‌ها و دام‌های رایج

بکنید

  • پیام خطا را مشخص، قابل اقدام، و با فارسی رسمی بنویسید — «آدرس ایمیل نامعتبر است»، نه «ایمیلت اشتباهه» - از mode: 'onTouched' استفاده کنید تا اولین خطا هنگام ترک فیلد ظاهر شود و پس از اصلاح فوراً پاک شود - خطای فیلددار سرور را با setError روی همان فیلد بنشانید و خطای کلی را با Alert بالای فرم نمایش دهید - هنگام ارسال، دکمه را با isLoading={form.formState.isSubmitting} قفل کنید تا از ارسال مجدد جلوگیری شود

نکنید

  • دکمه ارسال را به‌خاطر نامعتبر بودن فرم disabled نکنید — کاربر نمی‌فهمد چه چیزی مانع ارسال است؛ اجازه دهید ارسال شود و خطاها زیر فیلدها نمایش یابند - از placeholder به‌جای برچسب استفاده نکنید — با شروع تایپ ناپدید می‌شود و صفحه‌خوان‌ها آن را برچسب حساب نمی‌کنند - متن خام خطای فنی سرور (پیام exception یا کد وضعیت) را مستقیم به کاربر نشان ندهید — آن را به پیام قابل فهم ترجمه کنید

دام‌های رایج

۱. سیم‌کشی دستی aria داخل FormControl

اشتباه: پاس دادن دستی aria-invalid، aria-describedby، یا id به کنترلی که داخل FormControl است. چرا مشکل‌ساز است: FormControl این مقادیر را خودش از id تولیدشده FormItem می‌سازد؛ مقدار دستی شما (از طریق Slot) جایگزین مقدار خودکار می‌شود و اگر با id واقعی FormMessage یا FormDescription نخواند، صفحه‌خوان پیام اشتباه می‌خواند یا اصلاً چیزی نمی‌خواند — و اتصال توضیح فیلد در حالت بدون خطا از دست می‌رود. الگوی درست: داخل FormControl هیچ aria-* و id دستی ندهید. سیم‌کشی دستی فقط وقتی لازم است که خارج از react-hook-form کار می‌کنید (مثلاً با FormItemLayout مستقل و prop صریح error).

۲. وارد کردن 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, FormItem } from '@partodata/ui/form' — بقیه (مثل Input و Button) از barrel اصلی.

۳. رنگ هاردکد برای حالت خطا

اشتباه: استایل‌دادن پیام یا حاشیه خطا با رنگ ثابت:

// ❌ فقط در یک تم درست دیده می‌شود و lint را رد نمی‌کند
<p className="text-red-500">فرمت ایمیل صحیح نیست</p>

// ✅ کامپوننت‌های Form* خودشان توکن‌محورند؛ برای موارد سفارشی هم از توکن استفاده کنید
<p className="text-destructive text-sm">فرمت ایمیل صحیح نیست</p>

چرا مشکل‌ساز است: این دیزاین‌سیستم dark-first است (تم پایه :root تیره است) و همه رنگ‌ها باید از توکن‌ها بیایند؛ text-red-500 در یکی از دو تم شکسته دیده می‌شود و قانون ESLint no-hardcoded-colors آن را رد می‌کند. الگوی درست: به FormMessage و FormLabel تکیه کنید که خودشان از text-destructive استفاده می‌کنند؛ هر استایل سفارشی خطا هم فقط با توکن‌های --destructive.

۴. ویژگی‌های فیزیکی CSS در چیدمان فرم

اشتباه: فاصله‌گذاری ستاره الزامی، آیکون داخل فیلد، یا تراز پیام خطا با ویژگی‌های فیزیکی مثل ml-1، pl-3 یا text-left. چرا مشکل‌ساز است: این سیستم RTL-first است؛ ویژگی فیزیکی در RTL آینه نمی‌شود — ستاره به سمت اشتباه می‌چسبد و پیام خطا زیر فیلد به سمت مخالف تراز می‌شود. قانون ESLint no-physical-css-properties هم آن را رد می‌کند. الگوی درست: معادل منطقی — ms-1، ps-3، text-start. تنها استثنای مجاز، محتوای ذاتاً LTR مثل خود آدرس ایمیل است که با dir="ltr" روی Input مدیریت می‌شود، نه با کلاس فیزیکی.

۵. اعتبارسنجی یکتایی سمت کلاینت روی داده صفحه‌بندی‌شده سروری

اشتباه: در فرمی که کنار یک DataTable با صفحه‌بندی سمت سرور می‌نشیند (مثلاً «کمپین جدید»)، یکتایی نام را با جستجو در ردیف‌های بارگذاری‌شده جدول بررسی کنید: rows.some((r) => r.name === value). چرا مشکل‌ساز است: در جدول سروری فقط ردیف‌های صفحه فعلی در کلاینت موجودند؛ نام تکراری که در صفحه دیگری است از اعتبارسنجی رد می‌شود و کاربر به‌جای خطای فیلد، با خطای مبهم سرور هنگام ثبت مواجه می‌شود. الگوی درست: یکتایی را سمت سرور بررسی کنید و پاسخ خطا (مثلاً 409) را با setError('name', { message: 'کمپینی با این نام از قبل وجود دارد' }) روی همان فیلد بنشانید — همان الگوی «نمایش خطای سرور» بالا.

صفحات مرتبط

  • اشتباهات رایج — فهرست کامل ضدالگوهای سراسری (رنگ hardcode، خواص فیزیکی CSS و …)؛ دام‌های این صفحه نمونه‌های همان ریشه‌ها در این الگو هستند.
  • الگوهای فرم — اگر پرسش شما فراتر از اعتبارسنجی است (چیدمان برچسب‌ها، فرم چندمرحله‌ای، ساختار کلی)، اول آن راهنما را ببینید.
  • فرم (Form) — وقتی همین الگو را می‌خواهید و فقط مرجع کامل کامپوننت‌های Form* و props آن‌ها لازم دارید.
  • FormItemLayout — اگر فقط داربست برچسب/توضیح/خطا را بدون وابستگی به react-hook-form می‌خواهید و خطا را خودتان با prop صریح error پاس می‌دهید.
  • الگوهای خطا — برای خطاهای سطح صفحه و API (بیرون از فیلدهای فرم)، نمایش را با این الگوها هماهنگ کنید.