TanStack Form v1 مع Next.js 15: دليل شامل لبناء نماذج آمنة الأنواع

AI Bot
بواسطة AI Bot ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

النماذج موجودة في كل مكان في تطبيقات الويب، ومع ذلك فإن بناءها بشكل صحيح أصعب مما يبدو. إدارة الحالة، التحقق، إمكانية الوصول، التكامل مع الخادم، والأداء كلها تتصادم في أحد أكثر الأسطح عرضة للأخطاء في قاعدة الشيفرة. تم تصميم TanStack Form v1 لحل هذه المشكلة من خلال أدوات أساسية مستقلة عن إطار العمل وآمنة الأنواع بالكامل من اسم الحقل إلى البيانات المرسلة.

في هذا الدليل، ستبني نموذج متعدد الخطوات كامل في Next.js 15 App Router باستخدام TanStack Form v1. ستقوم بتنفيذ التحقق المتزامن وغير المتزامن باستخدام Zod، مصفوفات الحقول الديناميكية، التكامل مع Next.js Server Actions، وتدفق إرسال جاهز للإنتاج.

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

قبل البدء، تأكد من توفر ما يلي:

  • Node.js 20 أو أحدث
  • إلمام بـ Next.js App Router و React Server Components
  • معرفة عملية بـ React hooks و TypeScript generics
  • محرر شيفرة مثل VS Code
  • مدير حزم مثل npm أو pnpm أو bun

ما الذي ستبنيه

نموذج طلب وظيفة يتكون من ثلاثة أقسام:

  1. المعلومات الشخصية مع التحقق المتزامن
  2. الخبرة العملية كمصفوفة حقول ديناميكية
  3. روابط المحفظة مع التحقق غير المتزامن من الروابط

يحفظ النموذج التقدم، ويقدم من خلال Next.js server action، ويتعامل مع الأخطاء بشكل سلس.

الخطوة 1: إعداد المشروع

أنشئ تطبيق Next.js جديد وقم بتثبيت التبعيات.

npx create-next-app@latest tanstack-form-demo --typescript --app --tailwind --no-src-dir
cd tanstack-form-demo
npm install @tanstack/react-form @tanstack/zod-form-adapter zod

يتم توزيع TanStack Form كحزم منفصلة حتى لا تقوم بتجميع سوى ما تحتاجه. ربط React هو @tanstack/react-form، و @tanstack/zod-form-adapter يوفر جسر التحقق الخاص بـ Zod.

تحقق من التثبيت عبر تشغيل خادم التطوير.

npm run dev

الخطوة 2: تعريف مخطط النموذج باستخدام Zod

يبدأ الكتابة القوية بالمخطط. أنشئ lib/schemas/application.ts.

import { z } from "zod"
 
export const ExperienceSchema = z.object({
  company: z.string().min(1, "اسم الشركة مطلوب"),
  role: z.string().min(1, "المسمى الوظيفي مطلوب"),
  years: z.number().min(0).max(50),
})
 
export const ApplicationSchema = z.object({
  fullName: z.string().min(2, "يجب أن يكون الاسم على الأقل حرفين"),
  email: z.string().email("عنوان بريد غير صالح"),
  phone: z.string().regex(/^\+?[0-9\s-]{8,}$/, "رقم هاتف غير صالح"),
  experience: z.array(ExperienceSchema).min(1, "أضف خبرة واحدة على الأقل"),
  portfolio: z.array(z.string().url("يجب أن يكون رابط صالح")).optional(),
  coverLetter: z.string().min(50, "يجب أن يكون خطاب التغطية على الأقل 50 حرف"),
})
 
export type Application = z.infer<typeof ApplicationSchema>
export type Experience = z.infer<typeof ExperienceSchema>

يصبح المخطط المصدر الوحيد للحقيقة. سيستخدمه TanStack Form للتحقق من الحقول واستنتاج جميع أنواع النموذج تلقائياً.

الخطوة 3: إنشاء مكون حقل قابل لإعادة الاستخدام

TanStack Form بدون رأس، لذا فأنت تتحكم في كل جزء من الترميز. أنشئ components/form/TextField.tsx.

import type { AnyFieldApi } from "@tanstack/react-form"
 
interface TextFieldProps {
  field: AnyFieldApi
  label: string
  type?: string
  placeholder?: string
}
 
export function TextField({ field, label, type = "text", placeholder }: TextFieldProps) {
  const errors = field.state.meta.errors
  const hasError = field.state.meta.isTouched && errors.length > 0
 
  return (
    <div className="flex flex-col gap-1">
      <label htmlFor={field.name} className="text-sm font-medium">
        {label}
      </label>
      <input
        id={field.name}
        name={field.name}
        type={type}
        placeholder={placeholder}
        value={field.state.value ?? ""}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.value)}
        aria-invalid={hasError}
        aria-describedby={hasError ? `${field.name}-error` : undefined}
        className="rounded border px-3 py-2 focus:ring-2 focus:ring-blue-500"
      />
      {hasError && (
        <span id={`${field.name}-error`} className="text-sm text-red-600">
          {errors.map((err: { message?: string } | string) =>
            typeof err === "string" ? err : err.message
          ).join(", ")}
        </span>
      )}
    </div>
  )
}

لاحظ ثلاثة أشياء: سمات aria-invalid و aria-describedby لقارئات الشاشة، معالج onBlur الذي يعلم الحقل بأنه تم لمسه، وحقيقة أن الأخطاء تظهر فقط بعد أن يتفاعل المستخدم مع الحقل. هذه الأنماط الثلاثة تمنع أكثر أخطاء إمكانية الوصول شيوعاً في نماذج React.

الخطوة 4: بناء نموذج الطلب

أنشئ app/apply/page.tsx.

"use client"
 
import { useForm } from "@tanstack/react-form"
import { zodValidator } from "@tanstack/zod-form-adapter"
import { ApplicationSchema } from "@/lib/schemas/application"
import { TextField } from "@/components/form/TextField"
import { submitApplication } from "./actions"
 
export default function ApplyPage() {
  const form = useForm({
    defaultValues: {
      fullName: "",
      email: "",
      phone: "",
      experience: [{ company: "", role: "", years: 0 }],
      portfolio: [],
      coverLetter: "",
    },
    validatorAdapter: zodValidator(),
    validators: {
      onChange: ApplicationSchema,
    },
    onSubmit: async ({ value }) => {
      const result = await submitApplication(value)
      if (!result.success) {
        throw new Error(result.error)
      }
    },
  })
 
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        e.stopPropagation()
        form.handleSubmit()
      }}
      className="mx-auto max-w-2xl space-y-6 p-6"
    >
      <h1 className="text-2xl font-bold">طلب وظيفة</h1>
 
      <form.Field name="fullName">
        {(field) => <TextField field={field} label="الاسم الكامل" />}
      </form.Field>
 
      <form.Field name="email">
        {(field) => <TextField field={field} label="البريد الإلكتروني" type="email" />}
      </form.Field>
 
      <form.Field name="phone">
        {(field) => <TextField field={field} label="الهاتف" type="tel" />}
      </form.Field>
    </form>
  )
}

يستخدم مكون form.Field نمط render props. داخل دالة العرض، يمنحك field كل ما تحتاجه: القيمة الحالية، الأخطاء، المعالجات، وحالة مستوى الحقل. لأن useForm استقبل defaultValues، يستنتج TypeScript الشكل وسيظهر خطأ في وقت التجميع إذا كتبت form.Field name="fullname" بحالة أحرف خاطئة.

الخطوة 5: إضافة مصفوفات الحقول الديناميكية

الخبرة العملية قائمة تنمو وتتقلص. يوفر TanStack Form دعماً من الدرجة الأولى لذلك عبر form.Field مع خيار mode="array".

أضف أسفل حقل الهاتف.

<form.Field name="experience" mode="array">
  {(field) => (
    <div className="space-y-4">
      <div className="flex items-center justify-between">
        <h2 className="text-lg font-semibold">الخبرة العملية</h2>
        <button
          type="button"
          onClick={() =>
            field.pushValue({ company: "", role: "", years: 0 })
          }
          className="rounded bg-blue-600 px-3 py-1 text-white"
        >
          إضافة خبرة
        </button>
      </div>
 
      {field.state.value.map((_, index) => (
        <div key={index} className="space-y-2 rounded border p-4">
          <form.Field name={`experience[${index}].company`}>
            {(subField) => <TextField field={subField} label="الشركة" />}
          </form.Field>
          <form.Field name={`experience[${index}].role`}>
            {(subField) => <TextField field={subField} label="المسمى" />}
          </form.Field>
          <form.Field name={`experience[${index}].years`}>
            {(subField) => (
              <TextField field={subField} label="السنوات" type="number" />
            )}
          </form.Field>
          <button
            type="button"
            onClick={() => field.removeValue(index)}
            className="text-sm text-red-600"
          >
            إزالة
          </button>
        </div>
      ))}
    </div>
  )}
</form.Field>

تقوم طرق pushValue و removeValue بتعديل المصفوفة مع الحفاظ على أمان الأنواع. يتم كتابة كل حقل متداخل وفقاً للمخطط، لذا فإن experience[0].years معروف بأنه رقم.

الخطوة 6: التحقق غير المتزامن لروابط المحفظة

أحياناً يتطلب التحقق استدعاءً شبكياً. بالنسبة لروابط المحفظة، تريد التحقق من أن الرابط يُحل فعلاً. يدعم TanStack Form المدققات غير المتزامنة لكل حقل.

أضف قسم المحفظة إلى النموذج.

<form.Field
  name="portfolio"
  mode="array"
  validators={{
    onChangeAsync: async ({ value }) => {
      if (!value || value.length === 0) return undefined
      const checks = await Promise.all(
        value.map(async (url) => {
          try {
            const res = await fetch(`/api/check-url?url=${encodeURIComponent(url)}`)
            const data = await res.json()
            return data.ok ? null : `${url} غير قابل للوصول`
          } catch {
            return `تعذر التحقق من ${url}`
          }
        })
      )
      const errors = checks.filter(Boolean)
      return errors.length > 0 ? errors.join(", ") : undefined
    },
    onChangeAsyncDebounceMs: 500,
  }}
>
  {(field) => (
    <div className="space-y-2">
      <h2 className="text-lg font-semibold">روابط المحفظة</h2>
      {field.state.value?.map((_, index) => (
        <form.Field key={index} name={`portfolio[${index}]`}>
          {(sub) => <TextField field={sub} label={`رابط ${index + 1}`} />}
        </form.Field>
      ))}
      <button
        type="button"
        onClick={() => field.pushValue("")}
        className="rounded border px-3 py-1"
      >
        إضافة رابط
      </button>
    </div>
  )}
</form.Field>

يمنع خيار onChangeAsyncDebounceMs إرهاق الخادم أثناء كتابة المستخدم. يتعامل TanStack Form مع التأجيل، وحالات التسابق، والإلغاء داخلياً.

أنشئ مسار API في app/api/check-url/route.ts.

import { NextResponse } from "next/server"
 
export async function GET(request: Request) {
  const url = new URL(request.url).searchParams.get("url")
  if (!url) return NextResponse.json({ ok: false })
 
  try {
    const res = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(3000) })
    return NextResponse.json({ ok: res.ok })
  } catch {
    return NextResponse.json({ ok: false })
  }
}

الخطوة 7: التكامل مع Next.js Server Actions

يقدم النموذج من خلال server action. أنشئ app/apply/actions.ts.

"use server"
 
import { ApplicationSchema, type Application } from "@/lib/schemas/application"
import { revalidatePath } from "next/cache"
 
export async function submitApplication(data: Application) {
  const parsed = ApplicationSchema.safeParse(data)
  if (!parsed.success) {
    return {
      success: false as const,
      error: "بيانات طلب غير صالحة",
      issues: parsed.error.flatten(),
    }
  }
 
  try {
    await saveToDatabase(parsed.data)
    revalidatePath("/apply")
    return { success: true as const }
  } catch (error) {
    const message = error instanceof Error ? error.message : "خطأ غير معروف"
    return { success: false as const, error: message }
  }
}
 
async function saveToDatabase(data: Application) {
  console.info("حفظ الطلب", data.email)
}

دائماً أعد التحقق على الخادم حتى لو قام العميل بالتحقق. العميل ليس المصدر الحقيقي أبداً.

الخطوة 8: زر الإرسال مع Subscribe

يستخدم TanStack Form نموذج اشتراك دقيق. المكونات التي تقرأ جزءاً من الحالة فقط ستعاد معالجتها عند تغير ذلك الجزء. استخدم form.Subscribe لمراقبة حالة زر الإرسال.

<form.Subscribe
  selector={(state) => [state.canSubmit, state.isSubmitting]}
>
  {([canSubmit, isSubmitting]) => (
    <button
      type="submit"
      disabled={!canSubmit}
      className="w-full rounded bg-blue-600 py-3 font-semibold text-white disabled:opacity-50"
    >
      {isSubmitting ? "جاري الإرسال..." : "إرسال الطلب"}
    </button>
  )}
</form.Subscribe>

لا تتم إعادة معالجة بقية النموذج عندما ينقلب isSubmitting. يكون هذا مهماً عندما تحتوي النماذج على أكثر من 20 أو 30 حقل، لأن React خلاف ذلك سيقوم بمطابقة الشجرة بأكملها عند كل ضغطة مفتاح.

الخطوة 9: حفظ التقدم في localStorage

يتوقع المستخدمون أن تنجو النماذج الطويلة من تحديث الصفحة. استخدم form.Subscribe لحفظ الحالة.

أضف داخل مكون الصفحة بعد useForm.

import { useEffect } from "react"
 
useEffect(() => {
  const stored = localStorage.getItem("application-draft")
  if (stored) {
    try {
      form.reset(JSON.parse(stored))
    } catch {
      console.warn("فشل استعادة المسودة")
    }
  }
}, [form])
 
useEffect(() => {
  const unsubscribe = form.store.subscribe(() => {
    localStorage.setItem("application-draft", JSON.stringify(form.state.values))
  })
  return unsubscribe
}, [form])

ينطلق هوك form.store.subscribe عند أي تغيير في الحالة، مما يتيح لك المزامنة مع المخازن الخارجية دون إطلاق إعادة معالجة React.

الخطوة 10: اختبار النموذج

قم بتثبيت Vitest و React Testing Library.

npm install -D vitest @testing-library/react @testing-library/user-event jsdom @vitejs/plugin-react

أنشئ app/apply/page.test.tsx.

import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import ApplyPage from "./page"
 
test("يظهر خطأ لبريد غير صالح", async () => {
  render(<ApplyPage />)
  const user = userEvent.setup()
  const emailInput = screen.getByLabelText(/البريد/i)
 
  await user.type(emailInput, "not-an-email")
  await user.tab()
 
  expect(await screen.findByText(/غير صالح/i)).toBeInTheDocument()
})
 
test("زر الإرسال معطل حتى يصبح النموذج صالحاً", () => {
  render(<ApplyPage />)
  const button = screen.getByRole("button", { name: /إرسال/i })
  expect(button).toBeDisabled()
})

اختبار التنفيذ الخاص بك

قم بتشغيل خادم التطوير واختبر كل سيناريو.

  1. ابدأ بنموذج فارغ وتحقق من أن زر الإرسال معطل
  2. املأ القسم الشخصي ببيانات غير صالحة وأكد أخطاء السطر
  3. أضف ثلاث إدخالات خبرة، أزل الأوسط، وأكد أن الأول والثالث يبقيان سليمين
  4. الصق رابطاً غير صالح في المحفظة ولاحظ خطأ async بعد 500ms
  5. قم بتحديث الصفحة وتحقق من استعادة تقدمك من localStorage

استكشاف الأخطاء

تظهر الأخطاء قبل أن يكتب المستخدم. ربما لديك validatorAdapter لكنك نسيت تغليفه بـ zodValidator(). استدعاء الدالة مطلوب.

تظهر الأنواع كـ any في مُصيِّري الحقول. تأكد من أن defaultValues مكتوب بالكامل. إذا استخدمت كائناً سطرياً، يستنتج TypeScript الشكل الدقيق. تمرير كائن جزئي سيوسع الأنواع.

التحقق غير المتزامن يطلق كثيراً. قم بزيادة onChangeAsyncDebounceMs إلى 800 أو استخدم onBlurAsync بدلاً من ذلك، والذي يعمل فقط عندما يفقد الحقل التركيز.

معالج الإرسال لا يُستدعى. تحقق من أنك منعت السلوك الافتراضي على عنصر النموذج ومن أن canSubmit صحيح. قم بتشغيل console.info(form.state.errors) داخل form.Subscribe لفحص أخطاء النموذج الكاملة.

الخطوات التالية

  • أضف تنقلاً متعدد الخطوات باستخدام form.state.fieldMeta للتحقق لكل خطوة
  • استبدل localStorage بمسودة من جانب الخادم باستخدام Upstash Redis
  • أضف تحميل الملفات باستخدام UploadThing المدمج مع TanStack Form
  • ابنِ حزمة form-kit قابلة لإعادة الاستخدام لـ monorepo الخاص بك باستخدام Turborepo

الخلاصة

يقدم TanStack Form v1 ما تعد به مكتبات النماذج الأخرى ولكنها نادراً ما تحققه: أمان أنواع حقيقي من البداية إلى النهاية، مرونة بدون رأس، وأداء يتوسع إلى نماذج بمئات الحقول. من خلال دمجه مع Zod للتحقق من المخطط و Next.js server actions للإرسال، لديك نمط جاهز للإنتاج يعمل بنفس الجودة لنموذج اتصال بسيط ومعالج متعدد الخطوات معقد.

التحول الذهني الرئيسي هو قبول أن النماذج هي آلات حالة. بمجرد أن ترى الحقول كاشتراكات فوق مخزن طبيعي، يصبح نموذج الأداء، وتوقيت التحقق، وعقد إمكانية الوصول كلها واضحة. يحصل مستخدموك على نماذج أسرع وأكثر موثوقية، ويتوقف فريقك عن إعادة اختراع نفس الأنماط المكسورة عبر كل مشروع.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على 5 أساسيات Laravel 11: Controllers.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

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

بناء تطبيق متكامل باستخدام Appwrite Cloud و Next.js 15

تعلّم كيفية بناء تطبيق ويب متكامل باستخدام Appwrite Cloud كخدمة خلفية و Next.js 15 مع App Router. يغطي هذا الدليل المصادقة وقواعد البيانات وتخزين الملفات والميزات الفورية.

30 د قراءة·