frontend

React Hook Form + Zod: دليل تعليمي للنماذج الآمنة برمجياً (Type-Safe) (2026)

٢٦ يونيو ٢٠٢٦

React Hook Form + Zod: Type-Safe Forms Tutorial (2026)

للتحقق من صحة نموذج React باستخدام Zod، قم بتثبيت React-hook-form و @hookform/resolvers، ومرر zodResolver(schema) إلى useForm، واقرأ أخطاء الحقول من formState.errors. يستنتج Zod أنواع (types) النموذج الخاص بك، لذا فإن مخططًا (schema) واحدًا يدير كلاً من التحقق من الصحة وأمان TypeScript.

ملخص

ستقوم ببناء نموذج تسجيل آمن تمامًا من حيث الأنواع (type-safe) باستخدام React Hook Form 7.80.0 و Zod 4.4.3، متصلين معًا بواسطة @hookform/resolvers 5.4.0. نحن نغطي إعداد zodResolver، و register للمدخلات الأصلية (native inputs)، و Controller للمكونات المتحكم بها (controlled components)، وخطأ TypeScript الذي يواجهه العديد من المطورين في المرة الأولى التي يستخدمون فيها z.coerce.number() — بالإضافة إلى إصلاح الـ generics للمدخلات والمخرجات. كل مقتطف هنا يتم فحصه نوعيًا (type-checks) تحت TypeScript 6 ويجتاز مجموعة اختبارات Testing Library حقيقية. الميزانية حوالي 20 دقيقة.

ما ستتعلمه

  • كيفية تثبيت وتثبيت إصدارات React Hook Form، و Zod resolver، و Zod
  • كيفية تعريف مخطط Zod واحد يقوم بالتحقق من الصحة وتحديد أنواع النموذج الخاص بك
  • كيفية ربط المخطط بـ zodResolver وعرض أخطاء الحقول
  • متى تستخدم register مقابل Controller
  • كيفية إصلاح خطأ الـ resolver "type is not assignable" الخاص بـ z.coerce
  • كيفية التعامل مع حالة الإرسال (submit state) وكتابة اختبار يثبت نجاحها
  • كيفية استكشاف وإصلاح خمسة أخطاء شائعة في React Hook Form + Zod

المتطلبات الأساسية

  • Node.js 20.19+ أو 22.12+ (مطلوب بواسطة Vite 8)
  • الإلمام بمكونات وظائف (function components) React والـ hooks
  • تطبيق React + TypeScript جديد. إذا لم يكن لديك واحد:
npm create vite@latest my-form-app -- --template React-ts
cd my-form-app

قم بتثبيت إصدارات تبعيات النموذج حتى يظل هذا البرنامج التعليمي قابلاً للتكرار:

npm install React-hook-form@7.80.0 @hookform/resolvers@5.4.0 zod@4.4.3

@hookform/resolvers هي حزمة الجسر الصغيرة التي تسمح لـ React Hook Form بتفويض التحقق من الصحة إلى مكتبة مخططات. تكتشف نقطة إدخال Zod الخاصة بها تلقائيًا ما إذا كنت تستخدم Zod 3 أو Zod 4، لذا فإن نفس استيراد zodResolver يعمل مع Zod 4.4.3.1

الخطوة 1: تعريف مخطط Zod

المخطط الواحد هو مصدر الحقيقة لكل من التحقق في وقت التشغيل (runtime validation) وأنواع TypeScript الخاصة بك. أنشئ src/schema.ts:

import { z } from 'zod';

export const signupSchema = z
  .object({
    name: z.string().min(2, 'Name must be at least 2 characters'),
    email: z.email('Enter a valid email address'),
    age: z.coerce.number().int().min(18, 'You must be at least 18'),
    password: z.string().min(8, 'Password must be at least 8 characters'),
    confirmPassword: z.string(),
    role: z.enum(['developer', 'designer', 'manager'], {
      error: 'Select a role',
    }),
    terms: z.literal(true, { error: 'You must accept the terms' }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  });

هناك ثلاث تفاصيل في Zod 4 تهمنا هنا. z.email() أصبح الآن مدققًا من المستوى الأعلى بدلاً من z.string().email().2 يقوم z.coerce.number() بتحويل السلسلة النصية التي ينتجها <input> دائمًا ("32") إلى number حقيقي، و z.literal(true) هي الطريقة التي تفرض بها خانة شروط "يجب تحديدها". الـ .refine() في النهاية هي قاعدة مشتركة بين الحقول: فهي تقارن بين password و confirmPassword وترفق أي خطأ بمسار confirmPassword.

هناك ملاحظة دقيقة يجب تذكرها: الـ .refine() المشترك بين الحقول على الكائن لا يعمل إلا بعد اجتياز كل فحص على مستوى الحقل. إذا كانت كلمة المرور قصيرة جدًا، فسترى خطأ "at least 8 characters" أولاً، ولن تظهر رسالة "Passwords do not match" إلا بمجرد أن يصبح كلا الحقلين صالحين بخلاف ذلك.

الخطوة 2: ربط useForm بـ zodResolver

الآن قم بتوصيل المخطط بـ React Hook Form. أنشئ src/SignupForm.tsx وابدأ بالـ hook:

import { useForm, Controller, type SubmitHandler } from 'React-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { signupSchema } from './schema';

type SignupInput = z.input<typeof signupSchema>;
type SignupOutput = z.output<typeof signupSchema>;

export function SignupForm({ onValid }: { onValid: (data: SignupOutput) => void }) {
  const {
    register,
    handleSubmit,
    control,
    formState: { errors, isSubmitting },
  } = useForm<SignupInput, unknown, SignupOutput>({
    resolver: zodResolver(signupSchema),
    mode: 'onTouched',
    defaultValues: {
      name: '',
      email: '',
      password: '',
      confirmPassword: '',
    },
  });

const onSubmit: SubmitHandler<SignupOutput> = (data) => {
    onValid(data);
  };

  // ...JSX النموذج في الخطوة التالية
}

zodResolver(signupSchema) هو التكامل بأكمله — سيقوم React Hook Form باستدعاء Zod في كل حدث تحقق وتحويل مشكلات Zod إلى كائن errors الخاص به.1 mode: 'onTouched' يتحقق من صحة الحقل بعد أول فقدان للتركيز (blur) ثم مباشرة عند كل تغيير، وهو الخيار الافتراضي الأكثر ودية لنماذج التسجيل. الـ generics الثلاثة في useForm (<SignupInput, unknown, SignupOutput>) ليست مجرد زينة اختيارية هنا — بل هي ما يجعل z.coerce يجتاز فحص النوع. سنشرح السبب بالضبط في الخطوة 5.

الخطوة 3: تسجيل المدخلات الأصلية وعرض الأخطاء

register هي الـ API الأساسية لـ React Hook Form للمدخلات الأصلية. إنها تعيد خصائص (props) الـ name، و onChange، و onBlur، و ref وتنشرها (spread) على العنصر، مما يبقي المدخل غير متحكم به (uncontrolled) — يقرأ React Hook Form القيمة من DOM عبر الـ ref بدلاً من تخزينها في حالة React. هذا النموذج غير المتحكم به هو ما يسمح للمكتبة بتقليل عمليات إعادة التصيير (re-renders) افتراضيًا.3 أضف الـ JSX داخل المكون:

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <input aria-label="name" {...register('name')} />
      {errors.name && <p role="alert">{errors.name.message}</p>}

      <input aria-label="email" type="email" {...register('email')} />
      {errors.email && <p role="alert">{errors.email.message}</p>}

      <input aria-label="age" type="number" {...register('age')} />
      {errors.age && <p role="alert">{errors.age.message}</p>}

      <input aria-label="password" type="password" {...register('password')} />
      {errors.password && <p role="alert">{errors.password.message}</p>}

      <input
        aria-label="confirmPassword"
        type="password"
        {...register('confirmPassword')}
      />
      {errors.confirmPassword && <p role="alert">{errors.confirmPassword.message}</p>}

      {/* الدور + الشروط + الإرسال يوضعون هنا في الخطوات التالية */}
    </form>
  );

handleSubmit يغلف الـ onSubmit الخاص بك بحيث لا يتم تشغيله إلا عند اجتياز التحقق من الصحة. يعيش كل خطأ في errors.<field>.message، وهو مملوء بالفعل بالسلسلة النصية الدقيقة من مخطط Zod الخاص بك. السمة role="alert" تعني أن قارئات الشاشة ستعلن عن إخفاقات التحقق فور ظهورها.

الخطوة 4: Controller للمكونات المتحكم بها

register يعمل مع عناصر input و select و textarea الأصلية. لكن العديد من النماذج الحقيقية تستخدم مكونات لا تقبل ref وبدلاً من ذلك تعرض الـ value/onChange الخاصة بها — مثل MUI أو React-select أو حقل نظام تصميم مخصص. بالنسبة لهؤلاء، قم بتغليف الحقل في Controller، والذي يربط المكون بالنموذج بدون ref.4 هنا يدير select للدور:

      <Controller
        control={control}
        name="role"
        render={({ field }) => (
          <select aria-label="role" {...field} value={field.value ?? ''}>
            <option value="">Select…</option>
            <option value="developer">Developer</option>
            <option value="designer">Designer</option>
            <option value="manager">Manager</option>
          </select>
        )}
      />
      {errors.role && <p role="alert">{errors.role.message}</p>}

      <input aria-label="terms" type="checkbox" {...register('terms')} />
      {errors.terms && <p role="alert">{errors.terms.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        Create account
      </button>

القاعدة العامة: الجأ إلى register أولاً لأنها أقل تكلفة، واستخدم Controller فقط عندما لا يمكن للمكون استقبال ref. خانة الاختيار terms هي مدخل أصلي، لذا تظل على register؛ أما الـ select الخاص بالدور فقد تم تغليفه لإظهار نمط Controller الذي ستعيد استخدامه لمدخلات الطرف الثالث.

الخطوة 5: إصلاح خطأ النوع في z.coerce

إذا قمت بكتابة useForm<SignupOutput> باستخدام generic واحد، سيرفض TypeScript الـ resolver الخاص بك بخطأ طويل ومربك:

Type 'Resolver<{ ...; age: unknown; ... }, any, { ...; age: number; ... }>'
is not assignable to type 'Resolver<{ ...; age: number; ... }>'.
  Type 'unknown' is not assignable to type 'number'.

هذا واحد من أكثر أخطاء TypeScript شيوعًا في React Hook Form + Zod، وهو مسجل عبر متتبعات الـ resolver و Zod.5 السبب دقيق: z.coerce.number() يقبل مدخلاً (input) من نوع unknown (سيقوم بتحويل أي شيء يكون عليه المدخل) ولكنه ينتج مخرجًا (output) من نوع number. لذا فإن مخططك يحتوي على نوعين مختلفين — القيم التي يحملها النموذج قبل التحقق، والقيم التي تحصل عليها بعده. الـ generic الواحد يجبرهما على أن يكونا من نفس النوع، وهما ليسا كذلك.

الإصلاح هو إعطاء React Hook Form كلا النوعين صراحةً، وهو بالضبط ما فعلناه في الخطوة 2:

type SignupInput = z.input<typeof signupSchema>;   // age: unknown
type SignupOutput = z.output<typeof signupSchema>; // age: number

useForm<SignupInput, unknown, SignupOutput>({
  resolver: zodResolver(signupSchema),
});

الـ generic الأول هو نوع قيم الحقول (field-values)، والثالث هو النوع الذي تم التحقق منه (المحول) الذي يتلقاه معالج الإرسال (submit handler) الخاص بك. مع توفير كليهما، يتم فحص نوع z.coerce بشكل نظيف ويكون onSubmit مكتوبًا بشكل صحيح: data.age هو number، وليس سلسلة نصية. إذا كنت تفضل عدم لمس الـ generics، فهناك بديلان ينتجان نفس نتيجة وقت التشغيل: استبدل z.coerce.number() بـ z.transform(Number).pipe(z.number())، أو تخلَّ عن التحويل (coercion) من المخطط وقم بتسجيل الحقل باستخدام register('age', { valueAsNumber: true }) حتى يقوم React Hook Form بتحويله نيابة عنك.5

التحقق: إثبات أنه يعمل من خلال اختبار

يجب أن يكون التحقق قابلاً للتشغيل، وليس مجرد "يبدو صحيحاً". قم بتثبيت أدوات الاختبار وأضف مجموعة اختبارات Vitest + Testing Library التي تقوم بإرسال النموذج فعلياً:

npm install -D vitest @testing-library/React @testing-library/user-event \
  @testing-library/jest-dom jsdom

أنشئ test/SignupForm.test.tsx:

import { render, screen } from '@testing-library/React';
import userEvent from '@testing-library/user-event';
import { z } from 'zod';
import { SignupForm } from '../src/SignupForm';
import { signupSchema } from '../src/schema';

type SignupValues = z.output<typeof signupSchema>;

it('shows validation errors on empty submit', async () => {
  const user = userEvent.setup();
  render(<SignupForm onValid={() => {}} />);
  await user.click(screen.getByRole('button', { name: /create account/i }));
  expect(
    await screen.findByText('Name must be at least 2 characters'),
  ).toBeInTheDocument();
  expect(screen.getByText('Enter a valid email address')).toBeInTheDocument();
  expect(screen.getByText('You must accept the terms')).toBeInTheDocument();
});

it('submits parsed values with age coerced to a number', async () => {
  const user = userEvent.setup();
  let received: SignupValues | undefined;
  render(<SignupForm onValid={(d) => { received = d; }} />);
  await user.type(screen.getByLabelText('name'), 'Ada Lovelace');
  await user.type(screen.getByLabelText('email'), 'ada@example.com');
  await user.type(screen.getByLabelText('age'), '32');
  await user.type(screen.getByLabelText('password'), 'sup3rsecret');
  await user.type(screen.getByLabelText('confirmPassword'), 'sup3rsecret');
  await user.selectOptions(screen.getByLabelText('role'), 'developer');
  await user.click(screen.getByLabelText('terms'));
  await user.click(screen.getByRole('button', { name: /create account/i }));
  expect(received).toBeDefined();
  expect(typeof received!.age).toBe('number');
  expect(received!.age).toBe(32);
});

قم بتشغيله باستخدام npx vitest run (أضف بيئة jsdom في vitest.config.ts). ينجح كلا الاختبارين: الإرسال الفارغ يُظهر رسائل Zod، والإرسال الصالح يسلم onSubmit بيانات يكون فيها age هو الرقم 32، وليس السلسلة النصية "32" — وهذا دليل على أن التحويل (coercion) والأنواع العامة للمدخلات/المخرجات تعمل بشكل متكامل.

الأخطاء الشائعة

Type 'unknown' is not assignable to type 'number' في الـ resolver. لقد استخدمت z.coerce (أو تحويلاً) مع نوع عام واحد في useForm. أضف أنواع المدخلات والمخرجات: useForm<z.input<typeof schema>, unknown, z.output<typeof schema>>. انظر الخطوة 5.5

الأخطاء لا تظهر أبداً. ربما نسيت القراءة من formState.errors، أو أن المدخل الخاص بك غير مسجل. تأكد من أن كل حقل يتم نشره باستخدام {...register('fieldName')} وأن الاسم يطابق مفتاحاً في المخطط (schema) تماماً.

رسالة "passwords do not match" لا تظهر. لا يتم تشغيل .refine() عبر الحقول إلا بعد أن يجتاز كل حقل فحوصاته الخاصة. املأ كلا كلمتي المرور بسلاسل نصية صالحة مكونة من 8 أحرف أو أكثر وتكون مختلفة، وسيعمل خطأ الـ refine.

age يصل كسلسلة نصية في onSubmit. بدون التحويل (coercion)، يظل <input type="number"> ينتج سلسلة نصية. استخدم z.coerce.number() في المخطط (كما هو موضح) أو register('age', { valueAsNumber: true }).

مكون محكوم (controlled component) يرمي خطأ "changing an uncontrolled input to controlled." قم بإعطاء الحقل مدخلاً في defaultValues، أو استخدم value={field.value ?? ''} كبديل داخل Controller كما فعلنا في اختيار الدور (role select).

الخطوات التالية ومزيد من القراءة

لديك الآن نموذج آمن الأنواع (type-safe) حيث يقود مخطط Zod واحد عمليات التحقق، ورسائل الخطأ، ونوع بيانات onSubmit. من هنا، استكشف مصفوفات الحقول (field arrays) للقوائم الديناميكية، والتحقق غير المتزامن (async validation) لفحوصات التفرد، والتكامل مع الخادم.

إذا كنت تقارن بين المكتبات، فقم بمقارنة هذا النهج مع TanStack Form و Zod، الذي يستخدم نموذجاً محكوماً وبدون محولات (adapter-free). لنقل البيانات التي تم التحقق منها إلى الخادم، انظر Next.js Server Actions مع React 19. وإذا كنت ترغب في مراجعة كيف تدعم حالة React مدخلات النموذج، فإن React props and state يغطي الأساسيات.

Footnotes

  1. React Hook Form — Resolvers (Zod and other schema validators). https://GitHub.com/React-hook-form/resolvers 2 3 4

  2. Zod documentation (v4). https://zod.dev

  3. React Hook Form — register API. https://React-hook-form.com/docs/useform/register

  4. React Hook Form — Controller API. https://React-hook-form.com/docs/usecontroller/controller 2

  5. React Hook Form resolvers — type mismatch with z.coerce.number() (issue #795). https://GitHub.com/React-hook-form/resolvers/issues/795 2 3 4

الأسئلة الشائعة

قم بتثبيت @hookform/resolvers ، ثم مرر resolver: zodResolver(yourSchema) إلى useForm . يقوم React Hook Form بتشغيل Zod عند كل حدث تحقق ويربط المشكلات بـ formState.errors . 1