---
title: 'React Hook Form + Zod: دليل تعليمي للنماذج الآمنة برمجياً (Type-Safe) (2026)'
description: 'ابنِ نماذج React type-safe باستخدام React Hook Form 7 و Zod 4: إعداد zodResolver، و register مقابل Controller، وحل مشكلة type-error باستخدام z.coerce. كل الأكواد قابلة للتشغيل.'
url: https://nerdleveltech.com/ar/react-hook-form-zod-resolver-tutorial
language: ar
date: '2026-06-26'
category: frontend
tags:
  - react hook form
  - zod
  - react
  - forms
  - validation
  - typescript
  - frontend
markdown_url: https://nerdleveltech.com/ar/react-hook-form-zod-resolver-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/ar/react-hook-form-zod-resolver-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# React Hook Form + Zod: دليل تعليمي للنماذج الآمنة برمجياً (Type-Safe) (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 جديد. إذا لم يكن لديك واحد:

```bash
npm create vite@latest my-form-app -- --template React-ts
cd my-form-app
```

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

```bash
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.[^resolvers]

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

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

```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()`.[^zod] يقوم `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:

```tsx
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` الخاص به.[^resolvers] `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) افتراضيًا.[^register] أضف الـ JSX داخل المكون:

```tsx
  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.[^controller] هنا يدير `select` للدور:

```tsx
      <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 الخاص بك بخطأ طويل ومربك:

```text
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.[^coerce] السبب دقيق: `z.coerce.number()` يقبل **مدخلاً (input)** من نوع `unknown` (سيقوم بتحويل أي شيء يكون عليه المدخل) ولكنه ينتج **مخرجًا (output)** من نوع `number`. لذا فإن مخططك يحتوي على نوعين مختلفين — القيم التي يحملها النموذج قبل التحقق، والقيم التي تحصل عليها بعده. الـ generic الواحد يجبرهما على أن يكونا من نفس النوع، وهما ليسا كذلك.

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

```ts
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 بتحويله نيابة عنك.[^coerce]

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

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

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

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

```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.[^coerce]

**الأخطاء لا تظهر أبداً.** ربما نسيت القراءة من `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).

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

**كيف يمكنني التحقق من صحة React Hook Form باستخدام Zod؟** قم بتثبيت `@hookform/resolvers`، ثم مرر `resolver: zodResolver(yourSchema)` إلى `useForm`. يقوم React Hook Form بتشغيل Zod عند كل حدث تحقق ويربط المشكلات بـ `formState.errors`.[^resolvers]

**هل يدعم React Hook Form إصدار Zod 4؟** نعم. يكتشف `@hookform/resolvers` 5.4.0 مخططات Zod 4 تلقائياً — سطر `import { zodResolver } from '@hookform/resolvers/zod'` لم يتغير عن Zod 3.[^resolvers]

**متى يجب أن أستخدم `Controller` بدلاً من `register`؟** استخدم `register` لعناصر `input` و `select` و `textarea` الأصلية. استخدم `Controller` للمكونات المحكومة التي تعرض `value`/`onChange` ولا تقبل ref، مثل مدخلات MUI أو React-select.[^controller]

**لماذا يسبب `z.coerce.number()` خطأ TypeScript؟** يجعل التحويل (Coercion) نوع إدخال المخطط `unknown` ونوع إخراجه `number`. قم بتزويد كليهما لـ `useForm` كـ `useForm<Input, unknown, Output>` حتى يتم فحص الأنواع في الـ resolver.[^coerce]

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

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

إذا كنت تقارن بين المكتبات، فقم بمقارنة هذا النهج مع [TanStack Form و Zod](https://nerdleveltech.com/tanstack-form-react-zod-validation-tutorial)، الذي يستخدم نموذجاً محكوماً وبدون محولات (adapter-free). لنقل البيانات التي تم التحقق منها إلى الخادم، انظر [Next.js Server Actions مع React 19](https://nerdleveltech.com/nextjs-16-server-actions-react-19-optimistic-ui-tutorial). وإذا كنت ترغب في مراجعة كيف تدعم حالة React مدخلات النموذج، فإن [React props and state](https://nerdleveltech.com/react-props-and-state-2025) يغطي الأساسيات.

[^resolvers]: React Hook Form — Resolvers (Zod and other schema validators). <https://GitHub.com/React-hook-form/resolvers>

[^zod]: Zod documentation (v4). <https://zod.dev>

[^register]: React Hook Form — `register` API. <https://React-hook-form.com/docs/useform/register>

[^controller]: React Hook Form — `Controller` API. <https://React-hook-form.com/docs/usecontroller/controller>

[^coerce]: React Hook Form resolvers — type mismatch with `z.coerce.number()` (issue #795). <https://GitHub.com/React-hook-form/resolvers/issues/795>
