تطوير الويب

Server Actions في Next.js 15: نماذج وmutations بلا API

6 دقائق للقراءة

السلسلة: هذا الدرس جزء من سلسلة React 19 وNext.js 15. للحصول على نظرة شاملة، اقرأ المقال الرئيسي.

لسنوات، النمط الافتراضي لإدارة نموذج في تطبيق React بدا كالتالي: useState لكل حقل، onSubmit يمنع الافتراضي، fetch('/api/...') نحو API route، رد مُفسَّر، تحديث حالة لعرض النتيجة. Server Actions، المستقرّة في Next.js 15، تكنس هذه السباكة.

المتطلبات

  • Next.js 15.x مع App Router
  • فهم Server vs Client Components
  • قراءة درس data fetching
  • اختياري: Zod مثبَّت (pnpm add zod)
  • 60-80 دقيقة

الخطوة 1 — الشكل الأبسط

Server Action دالة async موسومة "use server". يمكن إعلانها في ملف مخصّص أو داخل Server Component.

// src/app/produits/[slug]/page.tsx
import { revalidatePath } from 'next/cache';

export default async function PageProduit({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;

  async function ajouterAvis(formData: FormData) {
    'use server';
    const note = Number(formData.get('note'));
    const texte = formData.get('texte') as string;
    // await prisma.avis.create({...})
    console.log('Nouvel avis recu', { slug, note, texte });
    revalidatePath(\`/produits/\${slug}\`);
  }

  return (
    <form action={ajouterAvis} className="space-y-3">
      <input name="note" type="number" min={1} max={5} required
        className="border rounded px-2 py-1" />
      <textarea name="texte" required
        className="border rounded px-2 py-1 w-full" />
      <button type="submit" className="bg-black text-white px-4 py-2 rounded">
        Envoyer
      </button>
    </form>
  );
}

هذا كل شيء. لا API route، لا useState، لا fetch على العميل. النموذج يُعرَض HTML خادمياً؛ عند التقديم، Next.js يُسلسل الحقول إلى multipart، يُطلق Server Action، ينتظر العودة، ثم يعيد عرض الصفحة. إضافة جوهرية: هذا النموذج يشتغل حتى بلا JavaScript محمَّل — progressive enhancement أصلي.

الخطوة 2 — التحقق بـ Zod

لا تثق أبداً ببيانات العميل. التحقق على جانب الخادم في Server Action.

// src/actions/avis.ts
'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';

const schemaAvis = z.object({
  slug: z.string().min(1),
  note: z.coerce.number().int().min(1).max(5),
  texte: z.string().min(10, 'Au moins 10 caracteres').max(2000),
});

export async function ajouterAvis(formData: FormData) {
  const data = schemaAvis.safeParse({
    slug: formData.get('slug'),
    note: formData.get('note'),
    texte: formData.get('texte'),
  });

  if (!data.success) {
    return { ok: false, erreurs: data.error.flatten().fieldErrors };
  }

  revalidatePath(\`/produits/\${data.data.slug}\`);
  return { ok: true };
}

أخرجنا Server Action في ملف مخصّص src/actions/avis.ts موسوم 'use server' في الأعلى: كل الملف لا يمكن استيراده إلا جانب الخادم.

الخطوة 3 — useActionState لإدارة العودة

// src/components/FormulaireAvis.tsx
'use client';

import { useActionState } from 'react';
import { ajouterAvis } from '@/actions/avis';

const etatInitial = { ok: false as boolean, erreurs: {} as Record<string, string[]> };

export function FormulaireAvis({ slug }: { slug: string }) {
  const [etat, action, isPending] = useActionState(
    async (_prev: typeof etatInitial, formData: FormData) => {
      const res = await ajouterAvis(formData);
      return res.ok ? { ok: true, erreurs: {} } : res;
    },
    etatInitial
  );

  return (
    <form action={action} className="space-y-3">
      <input type="hidden" name="slug" value={slug} />
      <input name="note" type="number" min={1} max={5} required />
      {etat.erreurs?.note && (
        <p className="text-sm text-red-600">{etat.erreurs.note[0]}</p>
      )}
      <textarea name="texte" required />
      {etat.erreurs?.texte && (
        <p className="text-sm text-red-600">{etat.erreurs.texte[0]}</p>
      )}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Envoi en cours...' : 'Envoyer'}
      </button>
      {etat.ok && <p className="text-sm text-green-600">شكراً، تم تسجيل الرأي.</p>}
    </form>
  );
}

useActionState يُرجع ثلاث قيم: الحالة الحالية، دالة action مُغلَّفة، وboolean isPending. كل boilerplate loading + error + success يقع في ثلاث متغيّرات.

الخطوة 4 — Optimistic UI بـ useOptimistic

// src/components/Like.tsx
'use client';

import { useOptimistic, useTransition } from 'react';
import { toggleLike } from '@/actions/like';

export function Like({ id, likes, liked }: { id: string; likes: number; liked: boolean }) {
  const [optimistic, setOptimistic] = useOptimistic(
    { likes, liked },
    (state, action: 'toggle') => ({
      likes: state.liked ? state.likes - 1 : state.likes + 1,
      liked: !state.liked,
    })
  );
  const [isPending, startTransition] = useTransition();

  return (
    <button
      onClick={() => startTransition(async () => {
        setOptimistic('toggle');
        await toggleLike(id);
      })}
      disabled={isPending}
      className={optimistic.liked ? 'text-red-600' : 'text-gray-600'}
    >
      {optimistic.liked ? '♥' : '♡'} {optimistic.likes}
    </button>
  );
}

النقرة تُحدّث الحالة المتفائلة فوراً (قلب ممتلئ، عدّاد متزايد)، ثم action الخادم تُنفَّذ. إن نجحت، إعادة العرض الخادمي تؤكّد الحالة. إن أخفقت، React يعود إلى الحالة السابقة.

الخطوة 5 — الأمان والحماية CSRF

Next.js 15 يتحقّق آلياً من Origin طلبات Server Actions ويُطبّق حماية CSRF أصلية. لكن فخّان يبقيان على عاتق المطوّر.

الفخ الأول: التفويض. Server Action مُصدَّر قابل للاستدعاء من أي شخص يخمّن URL الداخلي. تحقّق دائماً من الصلاحيات داخل action.

// src/actions/admin.ts
'use server';

import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';

export async function supprimerProduit(id: string) {
  const session = await auth();
  if (!session || session.role !== 'admin') {
    throw new Error('Non autorise');
  }
  redirect('/admin/produits');
}

الفخ الثاني: التحقق. تحقّق دائماً بـ Zod قبل لمس القاعدة. TypeScript يختفي عند التنفيذ.

الخطوة 6 — Server Actions خارج النموذج

// src/components/BoutonSupprimer.tsx
'use client';

import { useTransition } from 'react';
import { supprimerCommande } from '@/actions/commandes';

export function BoutonSupprimer({ id }: { id: string }) {
  const [isPending, startTransition] = useTransition();

  return (
    <button
      onClick={() => {
        if (!confirm('Supprimer cette commande ?')) return;
        startTransition(() => supprimerCommande(id));
      }}
      disabled={isPending}
    >
      {isPending ? 'Suppression...' : 'Supprimer'}
    </button>
  );
}

useTransition يسمح بوصف الاستدعاء كغير عاجل: إن نقر المستخدم خلال تشغيل action، React يحفظ UI متجاوباً. للأعمال الحرجة (دفع)، أضف idempotency key جانب الخادم.

الخطوة 7 — إدارة أخطاء الخادم

النمط الموصى به: ارجع الأخطاء المتوقَّعة في كائن الإرجاع، وأطلق استثناءً فقط للأخطاء غير المتوقّعة.

'use server';

export async function payerCommande(id: string) {
  try {
    const session = await auth();
    if (!session) return { ok: false, motif: 'auth' as const };

    const commande = await prisma.commande.findUnique({ where: { id } });
    if (!commande) return { ok: false, motif: 'introuvable' as const };
    if (commande.payee) return { ok: false, motif: 'deja-payee' as const };

    await stripe.charges.create({});
    await prisma.commande.update({ where: { id }, data: { payee: true } });
    return { ok: true };
  } catch (e) {
    console.error('payerCommande failed', e);
    throw e; // يصعد إلى error.tsx
  }
}

أخطاء شائعة

الخطأ السبب الحل
«Server Actions must be async functions» دالة موسومة "use server" غير async أضف async
النموذج لا يُقدَّم، لا خطأ مرئي action مُرَّر كـ onSubmit بدل action استخدم action={ajouterAvis} على <form>
بيانات النموذج كلها null جانب الخادم inputs بلا name أضف name="champ" على كل input
الصفحة لا تنعش بعد mutation نسيان revalidatePath أو revalidateTag استدع دالة revalidation في نهاية mutation ناجحة
«Can’t pass non-serializable values» تمرير دالة أو كائن معقّد كمعامل مرّر primitives، FormData أو plain objects فقط

الخطوة 8 — اختبار Server Action

أخرج المنطق التجاري في دالة pure مستقلة، اختبرها، وServer Action تصير wrapper نحيفاً.

// src/lib/avis-business.ts (قابل للاختبار مستقلاً)
export async function creerAvisLogique(input: {
  slug: string; note: number; texte: string; userId: string;
}) {
  if (input.note < 1 || input.note > 5) throw new Error('Note invalide');
  return prisma.avis.create({ data: input });
}

// src/actions/avis.ts
'use server';
import { creerAvisLogique } from '@/lib/avis-business';
export async function ajouterAvis(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error('Non authentifie');
  const res = await creerAvisLogique({ userId: session.user.id });
  revalidatePath('/produits');
  return { ok: true, id: res.id };
}

الخطوة 9 — الرصد في الإنتاج

Server Actions دوال serverless حين تشتغل على Vercel — لكلٍّ مدتها، cold start، أخطاؤها. اربط APM باكراً. Sentry، Axiom أو Datadog يدمجون في أسطر قليلة في instrumentation.ts ويلتقطون آلياً استثناءات Server Actions مع stack trace.

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

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é