تخطَّ إلى المحتوى

Server Actions والأخطاء الثلاثة المتكرّرة

الـ Server Action نقطة نهاية HTTP عامة باسم مُولَّد. معاملتها كاستدعاء دالّة هي الطريق الذي يسقط عنده التفويض والتحقّق من المدخلات.

5 دقيقة قراءة

تبدو الـ Server Action دالّة. تكتب async function، وتضع عليها 'use server'، وتمرّرها إلى نموذج، فتعمل على الخادم. سهولة الاستعمال كافية لأن تتوقّف عن التفكير فيما جرى فعلًا.

ما جرى أن Next.js أنشأ نقطة نهاية HTTP، وأعطاها معرّفًا مبهمًا، وربط النموذج ليرسل إليها POST. وكل من يستطيع قراءة حزمة جافاسكريبت لديك يستطيع استدعاءها، بأي وسائط يشاء، ومن أي مكان.

كل خطأ ممّا يلي ينبع من نسيان هذه الجملة وحدها.

أولًا: افتراض أن المستدعي موثوق

هذه النسخة هي الأكثر إطلاقًا:

'use server';
 
export async function deletePost(id: string) {
  await db.post.delete({ where: { id } });
  revalidatePath('/posts');
}

المكوّن لا يعرض الزر إلا للمؤلّف، إذن - هكذا يجري التفكير - لا يستطيع استدعاءها غير مؤلّف. ليست هكذا. نقطة النهاية موجودة سواء عُرِض الزر أم لا، وid هو ما يرسله المستدعي.

فوّض داخل الـ action نفسها، في كل مرّة، في مقابل الجلسة لا في مقابل الوسائط:

'use server';
 
export async function deletePost(id: string) {
  const session = await auth();
  if (!session) throw new Error('Unauthorised');
 
  // تُفحَص الملكية داخل الاستعلام، لا تُقرأ ثم تُقارَن - فالعبارتان تتركان
  // نافذة بين القراءة والحذف.
  const deleted = await db.post.deleteMany({
    where: { id, authorId: session.user.id },
  });
  if (deleted.count === 0) throw new Error('Not found');
 
  revalidatePath('/posts');
}

القاعدة التي نطبّقها في المراجعة: أوّل عبارة في أي action هي قراءة الجلسة. فإن كانت غير ذلك، فالـ action خاطئة حتى يشرح أحدهم لماذا لا.

ثانيًا: التحقّق داخل المكوّن

التحقّق في جهة العميل تسهيلٌ لمن يكتب. وليس تحقّقًا. فالـ action تستقبل FormData كما وصلت من الشبكة، وعليها أن تحلّلها:

'use server';
 
import { z } from 'zod';
 
const Schema = z.object({
  email: z.string().email(),
  message: z.string().min(20).max(5000),
});
 
export async function submit(prevState: State, formData: FormData) {
  const parsed = Schema.safeParse(Object.fromEntries(formData));
 
  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors };
  }
 
  await sendToInbox(parsed.data);
  return { ok: true };
}

إعادة الأخطاء بدل رميها هي ما يجعل النموذج صالحًا للاستعمال بلا جافاسكريبت، وأفضل معها:

'use client';
 
import { useActionState } from 'react';
 
export function ContactForm() {
  const [state, action, pending] = useActionState(submit, {});
 
  return (
    <form action={action}>
      <input name="email" type="email" required />
      {state.errors?.email ? <p role="alert">{state.errors.email[0]}</p> : null}
 
      <textarea name="message" required />
      {state.errors?.message ? <p role="alert">{state.errors.message[0]}</p> : null}
 
      <button disabled={pending}>{pending ? 'جارٍ الإرسال' : 'إرسال'}</button>
    </form>
  );
}

هذا النموذج يُرسِل قبل الترطيب، لأنه نموذج حقيقي يرسل إلى نقطة نهاية حقيقية. التحسين التدريجي هنا ليس عملًا إضافيًا، بل ما تحصل عليه حين لا تعاند المنصّة.

ثالثًا: التعديل بلا إبطال

تنجح الكتابة، وتبقى الصفحة تعرض البيانات القديمة، فيفتح أحدهم تذكرة عن التخزين المؤقّت. وليست مشكلة تخزين. لم يخبر أحدٌ الذاكرة.

revalidatePath('/posts');          // ذاكرة هذا المسار، على الخادم والعميل
revalidateTag('posts');            // كل طلب موسوم بـ 'posts'

استدعِ إحداهما داخل الـ action، بعد الكتابة. تمسح revalidatePath ذاكرة الخادم لذلك المسار وتُعلّم ذاكرة الموجّه في العميل قديمة - ولهذا لا يحتاج التعديل داخل action إلى شيء آخر، بينما التعديل نفسه في معالِج مسار مكتوب يدويًا يحتاج router.refresh() عند موضع الاستدعاء. ومعرفة أي ذاكرة هي أيّها تستحقّ الضبط: الطبقات الأربع وأيّها أوقعك.

متى لا تستعملها

الـ actions للتعديلات التي يملكها التطبيق نفسه. وهي الأداة الخطأ لـ:

  • أي شيء يستدعيه طرف ثالث. الـ webhooks تحتاج رابطًا ثابتًا وفحص توقيع. ذلك معالِج مسار.
  • قراءة البيانات. الـ action طلب POST. القراءة فيها تكلّفك الذاكرة المؤقّتة ولا تعطيك شيئًا؛ اجلب على الخادم داخل المكوّن.
  • رفع الملفات بأي حجم. تمرّ الـ actions عبر دالّة الخادم، بحدّ جسمها ووقت تنفيذها. وقّع رابطًا وارفع إلى التخزين مباشرة.
  • كل ما تريد تحديد معدّله حسب عنوان IP قبل أن يصل إلى شيفرتك. الوسيط ومعالِج المسار يعطيانك موضعًا للوقوف.

ما نفحصه قبل إطلاق أي action

أربعة أسطر، وتلتقط كل شيء تقريبًا:

  1. هل تبدأ بقراءة الجلسة؟
  2. هل تحلّل مدخلاتها بمخطّط، وتُعيد الأخطاء بدل رميها؟
  3. هل تُبطل ما غيّرته؟
  4. هل فحص الملكية داخل الاستعلام لا قراءةً يتبعها مقارنة؟

لا شيء من هذا غريب. إنه الانضباط نفسه الذي تحتاجه أي نقطة نهاية HTTP - وهذا هو المقصود بالضبط، لأن هذا هو ما تكونه الـ Server Action.

العودة إلى كل المقالات