إطار Next.js

معالجة النماذج والتحقق من الصحة

26 دقيقة الدرس 19 من 40

مقدمة إلى معالجة النماذج

النماذج ضرورية لتفاعل المستخدم في تطبيقات الويب. يوفر Next.js 13+ مع إجراءات الخادم أنماطاً قوية لمعالجة النماذج التي تكون بسيطة وآمنة في نفس الوقت. يغطي هذا الدرس التنفيذ الشامل للنماذج مع التحقق من الصحة، ومعالجة الأخطاء، وأفضل الممارسات.

النهج الحديث: تسمح لك إجراءات الخادم في Next.js بالتعامل مع إرسالات النماذج دون إنشاء مسارات API، مما يوفر تحسيناً تقدمياً تلقائياً ومعالجة مبسطة للأخطاء.

نموذج أساسي مع إجراءات الخادم

لنبدأ بنموذج اتصال بسيط باستخدام إجراءات الخادم:

<!-- app/contact/page.tsx -->
import { submitContactForm } from './actions'

export default function ContactPage() {
  return (
    <div className="max-w-md mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">اتصل بنا</h1>

      <form action={submitContactForm} className="space-y-4">
        <div>
          <label htmlFor="name" className="block text-sm font-medium mb-1">
            الاسم
          </label>
          <input
            type="text"
            id="name"
            name="name"
            required
            className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
          />
        </div>

        <div>
          <label htmlFor="email" className="block text-sm font-medium mb-1">
            البريد الإلكتروني
          </label>
          <input
            type="email"
            id="email"
            name="email"
            required
            className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
          />
        </div>

        <div>
          <label htmlFor="message" className="block text-sm font-medium mb-1">
            الرسالة
          </label>
          <textarea
            id="message"
            name="message"
            required
            rows={4}
            className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
          />
        </div>

        <button
          type="submit"
          className="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700"
        >
          إرسال الرسالة
        </button>
      </form>
    </div>
  )
}
<!-- app/contact/actions.ts -->
'use server'

export async function submitContactForm(formData: FormData) {
  const name = formData.get('name') as string
  const email = formData.get('email') as string
  const message = formData.get('message') as string

  // معالجة بيانات النموذج
  console.log({ name, email, message })

  // في تطبيق حقيقي، ستحفظ في قاعدة البيانات أو ترسل بريداً إلكترونياً
  // await prisma.contact.create({ data: { name, email, message } })

  return { success: true }
}

React Hook Form للنماذج المعقدة

للنماذج المعقدة مع العديد من الحقول ومنطق التحقق، يوفر React Hook Form أداءً ممتازاً وتجربة مطور رائعة:

# تثبيت React Hook Form
npm install react-hook-form
<!-- app/components/user-form.tsx -->
'use client'

import { useForm } from 'react-hook-form'
import { useState } from 'react'

interface UserFormData {
  firstName: string
  lastName: string
  email: string
  age: number
  phone: string
  address: string
  city: string
  zipCode: string
}

export function UserForm() {
  const [isSubmitting, setIsSubmitting] = useState(false)
  const {
    register,
    handleSubmit,
    formState: { errors },
    reset
  } = useForm<UserFormData>()

  const onSubmit = async (data: UserFormData) => {
    setIsSubmitting(true)
    try {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
      })

      if (response.ok) {
        alert('تم إنشاء المستخدم بنجاح!')
        reset()
      }
    } catch (error) {
      console.error('خطأ في الإرسال:', error)
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div className="grid grid-cols-2 gap-4">
        <div>
          <label className="block text-sm font-medium mb-1">
            الاسم الأول
          </label>
          <input
            {...register('firstName', {
              required: 'الاسم الأول مطلوب',
              minLength: {
                value: 2,
                message: 'يجب أن يكون الاسم الأول حرفين على الأقل'
              }
            })}
            className="w-full px-3 py-2 border rounded-lg"
          />
          {errors.firstName && (
            <p className="text-red-500 text-sm mt-1">
              {errors.firstName.message}
            </p>
          )}
        </div>

        <div>
          <label className="block text-sm font-medium mb-1">
            اسم العائلة
          </label>
          <input
            {...register('lastName', {
              required: 'اسم العائلة مطلوب'
            })}
            className="w-full px-3 py-2 border rounded-lg"
          />
          {errors.lastName && (
            <p className="text-red-500 text-sm mt-1">
              {errors.lastName.message}
            </p>
          )}
        </div>
      </div>

      <div>
        <label className="block text-sm font-medium mb-1">البريد الإلكتروني</label>
        <input
          type="email"
          {...register('email', {
            required: 'البريد الإلكتروني مطلوب',
            pattern: {
              value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
              message: 'عنوان بريد إلكتروني غير صالح'
            }
          })}
          className="w-full px-3 py-2 border rounded-lg"
        />
        {errors.email && (
          <p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium mb-1">العمر</label>
        <input
          type="number"
          {...register('age', {
            required: 'العمر مطلوب',
            min: { value: 18, message: 'يجب أن يكون عمرك 18 عاماً على الأقل' },
            max: { value: 120, message: 'عمر غير صالح' }
          })}
          className="w-full px-3 py-2 border rounded-lg"
        />
        {errors.age && (
          <p className="text-red-500 text-sm mt-1">{errors.age.message}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={isSubmitting}
        className="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
      >
        {isSubmitting ? 'جاري الإرسال...' : 'إرسال'}
      </button>
    </form>
  )
}
ميزة الأداء: يستخدم React Hook Form مكونات غير منضبطة ويقلل من إعادة العرض، مما يجعله مثالياً للنماذج ذات الحقول الكثيرة.

Zod للتحقق الآمن من النوع

توفر Zod فحص نوع وقت التشغيل والتحقق من الصحة الذي يعمل بسلاسة مع TypeScript:

# تثبيت Zod
npm install zod
<!-- lib/validations/user-schema.ts -->
import { z } from 'zod'

export const userSchema = z.object({
  firstName: z.string()
    .min(2, 'يجب أن يكون الاسم الأول حرفين على الأقل')
    .max(50, 'يجب أن يكون الاسم الأول أقل من 50 حرفاً'),

  lastName: z.string()
    .min(2, 'يجب أن يكون اسم العائلة حرفين على الأقل')
    .max(50, 'يجب أن يكون اسم العائلة أقل من 50 حرفاً'),

  email: z.string()
    .email('عنوان بريد إلكتروني غير صالح')
    .toLowerCase(),

  age: z.number()
    .int('يجب أن يكون العمر رقماً صحيحاً')
    .min(18, 'يجب أن يكون عمرك 18 عاماً على الأقل')
    .max(120, 'عمر غير صالح'),

  phone: z.string()
    .regex(/^\+?[1-9]\d{1,14}$/, 'رقم هاتف غير صالح'),

  password: z.string()
    .min(8, 'يجب أن تكون كلمة المرور 8 أحرف على الأقل')
    .regex(/[A-Z]/, 'يجب أن تحتوي كلمة المرور على حرف كبير واحد على الأقل')
    .regex(/[a-z]/, 'يجب أن تحتوي كلمة المرور على حرف صغير واحد على الأقل')
    .regex(/[0-9]/, 'يجب أن تحتوي كلمة المرور على رقم واحد على الأقل')
    .regex(/[^A-Za-z0-9]/, 'يجب أن تحتوي كلمة المرور على حرف خاص واحد على الأقل'),

  confirmPassword: z.string(),

  website: z.string().url('عنوان URL غير صالح').optional().or(z.literal('')),

  bio: z.string()
    .max(500, 'يجب أن تكون السيرة الذاتية أقل من 500 حرف')
    .optional(),

  terms: z.boolean()
    .refine(val => val === true, 'يجب عليك قبول الشروط والأحكام')
}).refine(data => data.password === data.confirmPassword, {
  message: 'كلمات المرور غير متطابقة',
  path: ['confirmPassword']
})

export type UserFormData = z.infer<typeof userSchema>

دمج React Hook Form مع Zod

ادمج React Hook Form مع Zod لأفضل تجربة مطور:

# تثبيت المحلل
npm install @hookform/resolvers
<!-- app/components/validated-form.tsx -->
'use client'

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { userSchema, type UserFormData } from '@/lib/validations/user-schema'

export function ValidatedForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset
  } = useForm<UserFormData>({
    resolver: zodResolver(userSchema),
    defaultValues: {
      firstName: '',
      lastName: '',
      email: '',
      age: 18,
      phone: '',
      password: '',
      confirmPassword: '',
      website: '',
      bio: '',
      terms: false
    }
  })

  const onSubmit = async (data: UserFormData) => {
    try {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
      })

      if (response.ok) {
        alert('تم التسجيل بنجاح!')
        reset()
      } else {
        const error = await response.json()
        alert(error.message || 'فشل التسجيل')
      }
    } catch (error) {
      console.error('خطأ في الإرسال:', error)
      alert('حدث خطأ. يرجى المحاولة مرة أخرى.')
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto p-6">
      <h2 className="text-2xl font-bold mb-6">نموذج التسجيل</h2>

      <div>
        <label className="block text-sm font-medium mb-1">الاسم الأول</label>
        <input
          {...register('firstName')}
          className="w-full px-3 py-2 border rounded-lg"
        />
        {errors.firstName && (
          <p className="text-red-500 text-sm mt-1">{errors.firstName.message}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium mb-1">اسم العائلة</label>
        <input
          {...register('lastName')}
          className="w-full px-3 py-2 border rounded-lg"
        />
        {errors.lastName && (
          <p className="text-red-500 text-sm mt-1">{errors.lastName.message}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium mb-1">البريد الإلكتروني</label>
        <input
          type="email"
          {...register('email')}
          className="w-full px-3 py-2 border rounded-lg"
        />
        {errors.email && (
          <p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium mb-1">العمر</label>
        <input
          type="number"
          {...register('age', { valueAsNumber: true })}
          className="w-full px-3 py-2 border rounded-lg"
        />
        {errors.age && (
          <p className="text-red-500 text-sm mt-1">{errors.age.message}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium mb-1">كلمة المرور</label>
        <input
          type="password"
          {...register('password')}
          className="w-full px-3 py-2 border rounded-lg"
        />
        {errors.password && (
          <p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium mb-1">تأكيد كلمة المرور</label>
        <input
          type="password"
          {...register('confirmPassword')}
          className="w-full px-3 py-2 border rounded-lg"
        />
        {errors.confirmPassword && (
          <p className="text-red-500 text-sm mt-1">{errors.confirmPassword.message}</p>
        )}
      </div>

      <div>
        <label className="flex items-center gap-2">
          <input
            type="checkbox"
            {...register('terms')}
            className="w-4 h-4"
          />
          <span className="text-sm">أوافق على الشروط والأحكام</span>
        </label>
        {errors.terms && (
          <p className="text-red-500 text-sm mt-1">{errors.terms.message}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={isSubmitting}
        className="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
      >
        {isSubmitting ? 'جاري الإرسال...' : 'تسجيل'}
      </button>
    </form>
  )
}

التحقق من جانب الخادم

تحقق دائماً من الخادم، حتى لو تحققت من العميل:

<!-- app/api/users/route.ts -->
import { NextResponse } from 'next/server'
import { userSchema } from '@/lib/validations/user-schema'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'

export async function POST(request: Request) {
  try {
    const body = await request.json()

    // التحقق من الصحة باستخدام Zod
    const validatedData = userSchema.parse(body)

    // التحقق من وجود البريد الإلكتروني
    const existingUser = await prisma.user.findUnique({
      where: { email: validatedData.email }
    })

    if (existingUser) {
      return NextResponse.json(
        { error: 'البريد الإلكتروني مسجل بالفعل' },
        { status: 400 }
      )
    }

    // تشفير كلمة المرور
    const hashedPassword = await bcrypt.hash(validatedData.password, 12)

    // إنشاء المستخدم
    const user = await prisma.user.create({
      data: {
        firstName: validatedData.firstName,
        lastName: validatedData.lastName,
        email: validatedData.email,
        age: validatedData.age,
        phone: validatedData.phone,
        hashedPassword,
        bio: validatedData.bio,
        website: validatedData.website,
      }
    })

    return NextResponse.json(
      { message: 'تم إنشاء المستخدم بنجاح', userId: user.id },
      { status: 201 }
    )
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: 'فشل التحقق من الصحة', issues: error.issues },
        { status: 400 }
      )
    }

    console.error('خطأ في التسجيل:', error)
    return NextResponse.json(
      { error: 'خطأ داخلي في الخادم' },
      { status: 500 }
    )
  }
}
الأمان: لا تثق أبداً في التحقق من جانب العميل وحده. تحقق دائماً من الخادم لمنع الطلبات الضارة من تجاوز منطق التحقق من الصحة الخاص بك.

إجراءات الخادم مع التحقق من الصحة

استخدم إجراءات الخادم مع Zod لمعالجة نماذج آمنة من حيث النوع:

<!-- app/actions/user-actions.ts -->
'use server'

import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import bcrypt from 'bcryptjs'

const createUserSchema = z.object({
  firstName: z.string().min(2),
  lastName: z.string().min(2),
  email: z.string().email(),
  password: z.string().min(8),
})

export async function createUser(prevState: any, formData: FormData) {
  try {
    const rawData = {
      firstName: formData.get('firstName'),
      lastName: formData.get('lastName'),
      email: formData.get('email'),
      password: formData.get('password'),
    }

    const validatedData = createUserSchema.parse(rawData)

    const existingUser = await prisma.user.findUnique({
      where: { email: validatedData.email }
    })

    if (existingUser) {
      return {
        success: false,
        error: 'البريد الإلكتروني مسجل بالفعل'
      }
    }

    const hashedPassword = await bcrypt.hash(validatedData.password, 12)

    await prisma.user.create({
      data: {
        ...validatedData,
        hashedPassword,
      }
    })

    revalidatePath('/users')

    return {
      success: true,
      message: 'تم إنشاء المستخدم بنجاح'
    }
  } catch (error) {
    if (error instanceof z.ZodError) {
      return {
        success: false,
        error: 'فشل التحقق من الصحة',
        issues: error.issues
      }
    }

    return {
      success: false,
      error: 'فشل إنشاء المستخدم'
    }
  }
}
<!-- app/components/user-form-with-action.tsx -->
'use client'

import { useFormState, useFormStatus } from 'react-dom'
import { createUser } from '../actions/user-actions'

function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button
      type="submit"
      disabled={pending}
      className="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
    >
      {pending ? 'جاري الإنشاء...' : 'إنشاء مستخدم'}
    </button>
  )
}

export function UserFormWithAction() {
  const [state, formAction] = useFormState(createUser, null)

  return (
    <form action={formAction} className="space-y-4 max-w-md mx-auto p-6">
      {state?.error && (
        <div className="p-3 text-red-500 bg-red-50 rounded-lg">
          {state.error}
        </div>
      )}

      {state?.success && (
        <div className="p-3 text-green-500 bg-green-50 rounded-lg">
          {state.message}
        </div>
      )}

      <div>
        <label className="block text-sm font-medium mb-1">الاسم الأول</label>
        <input
          type="text"
          name="firstName"
          required
          className="w-full px-3 py-2 border rounded-lg"
        />
      </div>

      <div>
        <label className="block text-sm font-medium mb-1">اسم العائلة</label>
        <input
          type="text"
          name="lastName"
          required
          className="w-full px-3 py-2 border rounded-lg"
        />
      </div>

      <div>
        <label className="block text-sm font-medium mb-1">البريد الإلكتروني</label>
        <input
          type="email"
          name="email"
          required
          className="w-full px-3 py-2 border rounded-lg"
        />
      </div>

      <div>
        <label className="block text-sm font-medium mb-1">كلمة المرور</label>
        <input
          type="password"
          name="password"
          required
          className="w-full px-3 py-2 border rounded-lg"
        />
      </div>

      <SubmitButton />
    </form>
  )
}
تمرين عملي:
  1. أنشئ نموذج تسجيل متعدد الخطوات باستخدام React Hook Form
  2. قم بتنفيذ التحقق من مخطط Zod مع رسائل خطأ مخصصة
  3. أضف التحقق غير المتزامن للتحقق مما إذا كان البريد الإلكتروني متاحاً
  4. أنشئ إجراء خادم يتحقق ويحفظ بيانات النموذج
  5. قم بتنفيذ تحديثات واجهة المستخدم المتفائلة باستخدام useOptimistic
  6. أضف تحميل الملفات مع التحقق من حجم الصورة ونوعها

أفضل ممارسات النماذج

  • تحقق دائماً من العميل والخادم
  • قدم رسائل خطأ واضحة ومفيدة
  • اعرض أخطاء التحقق مباشرة بالقرب من الحقول ذات الصلة
  • عطل أزرار الإرسال أثناء الإرسال
  • استخدم التحسين التقدمي مع إجراءات الخادم
  • قم بتنفيذ حالات التحميل والتعليقات المناسبة
  • امسح الحقول الحساسة (كلمات المرور) عند حدوث خطأ في الإرسال
  • استخدم أنواع الإدخال المناسبة (email، tel، url) لتحسين تجربة المستخدم

الخلاصة

معالجة النماذج الحديثة في Next.js تجمع بين إجراءات الخادم، React Hook Form، والتحقق من Zod لتنفيذات نماذج آمنة من حيث النوع وعالية الأداء وآمنة. تحقق دائماً من العميل والخادم، وقدم تعليقات واضحة، واستخدم التحسين التقدمي لأفضل تجربة مستخدم.