تطوير الويب

TanStack Form مع Zod: نماذج type-safe وتحقق خطوة بخطوة 2026

4 min de lecture

السلسلة: هذا الدرس جزء من سلسلة TanStack. اقرأ المقال الرئيسي.

كتابة نموذج React يكون type-safe، مُتحقَّقاً بنظافة، ومقروءاً، يبقى تمريناً مُحبِطاً في 2026. TanStack Form يقترح جواباً مختلفاً عن react-hook-form: يعتمد على معيار Standard Schema، يستخلص الأنواع من schema Zod (أو Valibot، أو ArkType) ويُقدّم API headless لا يهتمّ بالعرض ولا الـ style. تحافظ على تحكم كامل في JSX.

المتطلبات

  • Node.js 22 LTS وnpm 10+
  • مشروع React 19 مع TypeScript strict: true
  • محرّر مع خادم TS نشط
  • أساسيات Zod وتركيب schemas

الخطوة 1 — تثبيت TanStack Form وZod

npm install @tanstack/react-form zod

تأكّد @tanstack/react-form v1 مستقرّ، zod v4+. v4 يُحسّن الاستدلال ويُقدّم رسائل أخطاء i18n.

الخطوة 2 — تعريف schema Zod مصدر الحقيقة

// app/schemas/user.ts
import { z } from 'zod'

export const userSchema = z.object({
  email: z
    .string()
    .min(1, "L'email est requis")
    .email("Format d'email invalide"),
  age: z
    .number({ invalid_type_error: "L'age doit etre un nombre" })
    .int()
    .gte(13, 'Vous devez avoir au moins 13 ans'),
  password: z.string().min(8, 'Au moins 8 caracteres'),
})

export type UserInput = z.infer<typeof userSchema>

النوع UserInput مُستدلّ مرة واحدة: يساوي { email: string; age: number; password: string }. تعريف واحد، استخدامان (عميل + خادم)، صفر تكرار.

الخطوة 3 — تهيئة hook useForm

// app/components/SignupForm.tsx
import { useForm } from '@tanstack/react-form'
import { userSchema } from '~/schemas/user'

export function SignupForm() {
  const form = useForm({
    defaultValues: { email: '', age: 0, password: '' },
    validators: {
      onChange: userSchema,
      onSubmit: userSchema,
    },
    onSubmit: async ({ value }) => {
      console.log('Soumission validee :', value)
    },
  })
  return null
}

onChange: userSchema يُعيد التحقق عند كل ضربة مفتاح. onSubmit: userSchema يُعيد التحقق عند التقديم. handler onSubmit لا يُطلق إن أخفق التحقق.

الخطوة 4 — عرض الحقول مع form.Field

function SignupForm() {
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        void form.handleSubmit()
      }}
    >
      <form.Field name="email">
        {(field) => (
          <div>
            <label htmlFor={field.name}>Email</label>
            <input
              id={field.name}
              type="email"
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            {!field.state.meta.isValid && (
              <em role="alert">{field.state.meta.errors.join(', ')}</em>
            )}
          </div>
        )}
      </form.Field>

      <button type="submit">S'inscrire</button>
    </form>
  )
}

التنميط على اسم الحقل صارم: اكتب name="emial" وTypeScript يصرخ فوراً.

الخطوة 5 — إضافة حقل عددي وكلمة مرور

<form.Field name="age">
  {(field) => (
    <div>
      <label htmlFor={field.name}>Age</label>
      <input
        id={field.name}
        type="number"
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.valueAsNumber)}
      />
      {!field.state.meta.isValid && (
        <em role="alert">{field.state.meta.errors.join(', ')}</em>
      )}
    </div>
  )}
</form.Field>

<form.Field name="password">
  {(field) => (
    <div>
      <label htmlFor={field.name}>Mot de passe</label>
      <input
        id={field.name}
        type="password"
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.value)}
      />
      {!field.state.meta.isValid && (
        <em role="alert">{field.state.meta.errors.join(', ')}</em>
      )}
    </div>
  )}
</form.Field>

الحقل العددي يستخدم e.target.valueAsNumber الذي يُرجع رقماً مباشرة.

الخطوة 6 — تعطيل الزر بـ form.Subscribe

<form.Subscribe
  selector={(state) => [state.canSubmit, state.isSubmitting]}
  children={([canSubmit, isSubmitting]) => (
    <button type="submit" disabled={!canSubmit || isSubmitting}>
      {isSubmitting ? 'Envoi...' : "S'inscrire"}
    </button>
  )}
/>

طالما النموذج يحوي خطأً، canSubmit false والزر يبقى رمادياً. خلال التقديم، isSubmitting ينقلب true.

الخطوة 7 — التقديم نحو server function

// app/server/auth.ts
import { createServerFn } from '@tanstack/react-start'
import { userSchema } from '~/schemas/user'

export const signupServerFn = createServerFn({ method: 'POST' })
  .inputValidator(userSchema)
  .handler(async ({ data }) => {
    return { ok: true, userId: 'usr_' + Math.random().toString(36).slice(2) }
  })

// المكوّن
import { signupServerFn } from '~/server/auth'

const form = useForm({
  defaultValues: { email: '', age: 0, password: '' },
  validators: { onChange: userSchema, onSubmit: userSchema },
  onSubmit: async ({ value }) => {
    const result = await signupServerFn({ data: value })
    console.log('Inscrit :', result.userId)
  },
})

نفس userSchema يخدم التحقق جانب العميل (UX) وجانب الخادم (أمان). إن استدعى شخص ما server function بـ payload معدَّل، Zod يرفض الطلب قبل بلوغ قاعدة البيانات.

الخطوة 8 — عرض خطأ خادم عام

import { useState } from 'react'

function SignupForm() {
  const [serverError, setServerError] = useState<string | null>(null)

  const form = useForm({
    defaultValues: { email: '', age: 0, password: '' },
    validators: { onChange: userSchema, onSubmit: userSchema },
    onSubmit: async ({ value }) => {
      setServerError(null)
      try {
        await signupServerFn({ data: value })
      } catch (e) {
        setServerError(e instanceof Error ? e.message : 'Erreur inconnue')
      }
    },
  })

  return (
    <form onSubmit={(e) => { e.preventDefault(); void form.handleSubmit() }}>
      {serverError && <div role="alert">{serverError}</div>}
    </form>
  )
}

لحقن الخطأ في حقل محدّد، انظر field.setMeta({ errors: [...] }).

مقارنة TanStack Form مقابل react-hook-form

المعيار TanStack Form react-hook-form
Type-safety أصلي، بلا any قوي مع useForm<Type>()
Validation Zod Standard Schema، بلا adapter عبر @hookform/resolvers/zod
API Render-prop form.Field Hooks register وController
الأداء Selectors دقيقة (Subscribe) إعادة عرض معزولة لكل حقل
Async validation validators async أصلية عبر resolver
النظام البيئي ناشئ في 2026 واسع جداً
الحزمة ~10 KB gzippé ~9 KB gzippé

أخطاء شائعة

الخطأ السبب الحل
field.state.value undefined لا قيمة افتراضية أضف الحقل في defaultValues
Type mismatch على name خطأ كتابة في اسم الحقل اعتمد على autocomplete TS
حقل عددي يستلم سلسلة e.target.value بدل valueAsNumber استخدم e.target.valueAsNumber
الزر دائماً قابل للنقر لا form.Subscribe على canSubmit وصّل الاشتراك الدقيق
Validation لا تُطلق validators غير مُمرَّرة عرّف validators.onChange أو onSubmit
خطأ خادم مفقود لا try/catch في onSubmit خزّن الخطأ في useState محلي

أسئلة شائعة

هل يمكن TanStack Form بلا Zod؟ نعم. validators تقبل أيضاً دوال كلاسيكية تُرجع undefined أو رسالة خطأ. لكن الفائدة الرئيسية الاستدلال من schema، فـ Zod أو Valibot يبقى التركيب الاصطلاحي.

كيف نُدير حقل مصفوفة (مثل قائمة tags)؟ form.Field يقبل مسارات متداخلة (tags.0، tags.1)، أو استخدم form.useField({ name: 'tags' }) لمعالجة المصفوفة كاملة بـ pushValue وremoveValue.

كيف نُعيد تعيين نموذج بعد التقديم؟ form.reset() يُعيد القيم الافتراضية. استدعه بعد await server function في onSubmit.

هل يمكن ربط النموذج بحالة عالمية (Zustand، Redux)؟ نعم لكن غالباً غير مفيد. TanStack Form يدير حالته الخاصة؛ الحالة العالمية تستلم البيانات فقط عند التقديم.

هل validation async تشتغل؟ نعم، عبر validators: { onChangeAsync: async ({ value }) => { ... return errorString } }. فكّر بـ debounce على جانب المكوّن لتجنّب ضرب API عند كل ضربة مفتاح.

مقالات ذات صلة

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité