مقالات › React

آموزش فرم‌ها در React؛ ورودی کنترل‌شده، اعتبارسنجی و React Hook Form

فرم‌های مقاوم در React بسازید: ورودی کنترل‌شده و کنترل‌نشده، اعتبارسنجی و پیام خطا، ارسال به API، React Hook Form همراه با Zod، دسترس‌پذیری و مدیریت خطاهای سرور.

فرم‌ها جایی هستند که کاربر به برنامه‌ی شما داده می‌دهد: ثبت‌نام، پرداخت، تماس و پنل مدیریت. همچنین جایی هستند که بیشترین آزار تجربه‌ی کاربری رخ می‌دهد: ورودی از دست‌رفته، خطاهای مبهم و ارسال تکراری. این راهنما ساخت درست فرم در React را پوشش می‌دهد؛ از ورودی‌های کنترل‌شده‌ی ساده تا React Hook Form همراه با اعتبارسنجی مبتنی بر Schema.

ورودی کنترل‌شده و کنترل‌نشده

کنترل‌شده (Controlled) — State در React منبع حقیقت است:

function NewsletterForm() {
  const [email, setEmail] = useState("");
  return <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />;
}

کنترل‌نشده (Uncontrolled) — DOM مقدار را نگه می‌دارد و هر وقت لازم بود آن را می‌خوانید:

function SearchForm({ onSearch }) {
  const handleSubmit = (e) => {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    onSearch(data.get("q"));
  };
  return (
    <form onSubmit={handleSubmit}>
      <input name="q" defaultValue="" />
    </form>
  );
}

ورودی‌های کنترل‌شده اعتبارسنجی هنگام تایپ، قالب‌بندی مقدار یا فعال/غیرفعال کردن دکمه را آسان می‌کنند. ورودی‌های کنترل‌نشده کمتر رندر می‌شوند و برای فرم‌های ساده با FormData عالی کار می‌کنند. React Hook Form برای کارایی، داخلی از ورودی‌های کنترل‌نشده استفاده می‌کند.

یک فرم کنترل‌شده‌ی کامل

const initial = { name: "", email: "", message: "" };

function validate(values) {
  const errors = {};
  if (!values.name.trim()) errors.name = "نام خود را وارد کنید.";
  if (!/^\S+@\S+\.\S+$/.test(values.email)) errors.email = "ایمیل معتبر وارد کنید.";
  if (values.message.trim().length < 20) errors.message = "پیام باید حداقل ۲۰ کاراکتر باشد.";
  return errors;
}

function ContactForm() {
  const [values, setValues] = useState(initial);
  const [touched, setTouched] = useState({});
  const [status, setStatus] = useState("idle"); // idle | sending | sent | error
  const errors = validate(values);
  const isValid = Object.keys(errors).length === 0;

  const handleChange = (e) => setValues((v) => ({ ...v, [e.target.name]: e.target.value }));
  const handleBlur = (e) => setTouched((t) => ({ ...t, [e.target.name]: true }));

  async function handleSubmit(e) {
    e.preventDefault();
    setTouched({ name: true, email: true, message: true });
    if (!isValid || status === "sending") return;

    setStatus("sending");
    try {
      const res = await fetch("/api/contact", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(values),
      });
      if (!res.ok) throw new Error();
      setStatus("sent");
      setValues(initial);
      setTouched({});
    } catch {
      setStatus("error");
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <Field label="نام" name="name" value={values.name} error={touched.name && errors.name}
             onChange={handleChange} onBlur={handleBlur} />
      <Field label="ایمیل" name="email" type="email" value={values.email} error={touched.email && errors.email}
             onChange={handleChange} onBlur={handleBlur} dir="ltr" />
      <Field label="پیام" name="message" as="textarea" value={values.message}
             error={touched.message && errors.message} onChange={handleChange} onBlur={handleBlur} />

      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "در حال ارسال…" : "ارسال پیام"}
      </button>
      {status === "sent" && <p role="status">ممنون! به‌زودی پاسخ می‌دهم.</p>}
      {status === "error" && <p role="alert">مشکلی پیش آمد. دوباره تلاش کنید.</p>}
    </form>
  );
}

جزئیات تجربه‌ی کاربری که مهم‌اند:

  • خطاها را بعد از ترک فیلد (touched) یا هنگام ارسال نشان دهید؛ نه وقتی کاربر هنوز اولین حرف را تایپ می‌کند.
  • دکمه‌ی ارسال را حین ارسال غیرفعال کنید تا جلوی ارسال تکراری گرفته شود.
  • در صورت خطا ورودی کاربر را نگه دارید و فقط بعد از موفقیت پاکش کنید.
  • موفقیت را واضح تأیید کنید.

یک کامپوننت فیلد دسترس‌پذیر

function Field({ label, name, error, as: As = "input", ...props }) {
  const id = useId();
  const errorId = `${id}-error`;
  return (
    <div className="field">
      <label htmlFor={id}>{label}</label>
      <As id={id} name={name} aria-invalid={Boolean(error)} aria-describedby={error ? errorId : undefined} {...props} />
      {error && <span id={errorId} className="field__error">{error}</span>}
    </div>
  );
}
  • هر ورودی به یک <label> نیاز دارد (Placeholder برچسب نیست).
  • aria-invalid و aria-describedby به صفحه‌خوان‌ها اجازه می‌دهند خطا را اعلام کنند.
  • از type درست (email، tel، number) و مقادیر autoComplete استفاده کنید تا کیبورد موبایل و مدیر رمز عبور به کاربر کمک کنند.

React Hook Form + Zod

برای فرم‌های بزرگ‌تر، React Hook Form ثبت فیلدها، اعتبارسنجی، وضعیت Touched و ارسال را با کمترین رندر مدیریت می‌کند و Zod قوانین اعتبارسنجی را یک بار توصیف می‌کند:

npm install react-hook-form zod @hookform/resolvers
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

const schema = z
  .object({
    email: z.string().email("ایمیل معتبر وارد کنید."),
    password: z.string().min(8, "حداقل ۸ کاراکتر."),
    confirm: z.string(),
    acceptTerms: z.literal(true, { errorMap: () => ({ message: "باید قوانین را بپذیرید." }) }),
  })
  .refine((d) => d.password === d.confirm, { path: ["confirm"], message: "رمزها یکسان نیستند." });

function RegisterForm() {
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors, isSubmitting },
  } = useForm({ resolver: zodResolver(schema), mode: "onBlur" });

  const onSubmit = async (data) => {
    const res = await fetch("/api/auth/register", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
    if (res.status === 409) {
      setError("email", { message: "این ایمیل قبلاً ثبت شده است." });  // خطای سرور روی یک فیلد
      return;
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <input type="email" autoComplete="email" dir="ltr" {...register("email")} />
      {errors.email && <span role="alert">{errors.email.message}</span>}

      <input type="password" autoComplete="new-password" {...register("password")} />
      {errors.password && <span role="alert">{errors.password.message}</span>}

      <input type="password" autoComplete="new-password" {...register("confirm")} />
      {errors.confirm && <span role="alert">{errors.confirm.message}</span>}

      <label><input type="checkbox" {...register("acceptTerms")} /> قوانین را می‌پذیرم</label>
      {errors.acceptTerms && <span role="alert">{errors.acceptTerms.message}</span>}

      <button disabled={isSubmitting}>{isSubmitting ? "در حال ساخت حساب…" : "ثبت‌نام"}</button>
    </form>
  );
}

مزایا: داده‌ی فرم تایپ‌شده (z.infer<typeof schema>)، اعتبارسنجی یکدست، وضعیت ارسال ناهمگام و setError برای برگرداندن خطاهای سمت سرور (مثل ValidationProblemDetails در ASP.NET Core) به فیلدها.

نگاشت خطاهای اعتبارسنجی ASP.NET Core

if (res.status === 400) {
  const problem = await res.json();              // { errors: { Email: ["..."], Password: ["..."] } }
  for (const [field, messages] of Object.entries(problem.errors ?? {})) {
    setError(field.charAt(0).toLowerCase() + field.slice(1), { message: messages[0] });
  }
}

اعتبارسنجی سمت کلاینت تجربه‌ی کاربری را بهتر می‌کند؛ اعتبارسنجی سمت سرور الزامی است — هرگز به کلاینت اعتماد نکنید.

ورودی‌های خاص

  • فایل: از <input type="file" accept="image/*"> استفاده کنید، پیش‌نمایش را با URL.createObjectURL نشان دهید، اندازه را در کلاینت بررسی و با FormData ارسال کنید.
  • عدد و مبلغ: در حین ویرایش به‌صورت رشته نگه دارید و هنگام ارسال Parse کنید تا پرش نشانگر و مشکلات قالب‌بندی پیش نیاید.
  • ورودی فارسی: ارقام فارسی و عربی (۰-۹، ٠-٩) را قبل از اعتبارسنجی به لاتین تبدیل کنید و برای ایمیل، تلفن و آدرس اینترنتی dir="ltr" بگذارید.
  • تاریخ: برای کاربران فارسی‌زبان از Date Picker شمسی استفاده کنید و تاریخ ISO به API بفرستید.

اشتباهات رایج

  1. نمایش همه‌ی خطاها هنگام بارگذاری صفحه.
  2. نبود وضعیت بارگذاری ← کاربر پنج بار روی «ارسال» کلیک می‌کند.
  3. از دست رفتن همه‌ی ورودی‌ها بعد از خطای سرور.
  4. برچسب فقط با Placeholder (برای دسترس‌پذیری بد است و هنگام تایپ ناپدید می‌شود).
  5. اعتبارسنجی فقط در کلاینت.

جمع‌بندی

برای اعتبارسنجی تعاملی از ورودی کنترل‌شده و برای فرم‌های ساده از ورودی کنترل‌نشده (یا FormData) استفاده کنید، خطاها را در زمان درست نشان دهید، جلوی ارسال تکراری را بگیرید، با برچسب و ویژگی‌های ARIA فرم را دسترس‌پذیر نگه دارید و با بزرگ شدن فرم‌ها سراغ React Hook Form + Zod بروید. خطاهای اعتبارسنجی سرور را به فیلدها برگردانید و همیشه در سرور هم اعتبارسنجی کنید.

پرسش‌های متداول

آیا به کتابخانه‌ی فرم نیاز دارم؟

برای فرم‌های کوچک نه. برای فرم‌های طولانی، فیلدهای پویا، اعتبارسنجی پیچیده یا فرم‌های زیاد در یک برنامه، React Hook Form کد زیادی صرفه‌جویی می‌کند.

اعتبارسنجی هنگام onChange یا onBlur؟

onBlur (و بعد به‌روزرسانی زنده وقتی فیلد خطا دارد) پیش‌فرض کاربرپسندی است؛ onSubmit برای فرم‌های کوتاه مناسب است.

چطور جلوی ارسال تکراری را بگیرم؟

دکمه را غیرفعال و وضعیت ارسال را ردیابی کنید؛ در سرور هم کلیدهای Idempotency از عملیات حیاتی مثل پرداخت محافظت می‌کنند.