بازگشت به وبلاگ

فرم‌های Type-Safe در Next.js: ترکیب Server Actions، Zod و useActionState

۲ مرداد ۱۴۰۵3 دقیقه مطالعه
فرم‌های Type-Safe در Next.js: ترکیب Server Actions، Zod و useActionState

دقیقاً همین الگو — Server Action + اسکیمای Zod + useActionState — چیزیه که پشت هر فرم پنل ادمین این سایت کار می‌کنه. ارزش نوشتن درست‌وحسابیشو داره، چون جای یه مقدار غافلگیرکننده از چیزی رو می‌گیره که قبلاً به یه فرم‌لایبرری، یه API route، و یه کتابخونه‌ی validation سمت کلاینت نیاز داشت که همه‌شون دستی به هم وصل بشن.

شکل کلیش

  1. یه اسکیمای Zod تعریف می‌کنه داده‌ی معتبر چه شکلیه — فقط یه‌بار، مشترک بین پیام خطای قابل‌مشاهده‌ی کلاینت و validation سمت سرور.

  2. یه Server Action، FormData رو می‌خونه، با اسکیما validate‌ش می‌کنه، و یا خطای فیلد برمی‌گردونه یا خودِ mutation رو انجام می‌ده.

  3. useActionState یه فرم رو بدون هیچ fetch یا JSON دستی به اون اکشن وصل می‌کنه، و حتی بدون جاوااسکریپت هم کار می‌کنه (progressive enhancement) چون یه <form action> واقعیه.

۱. اسکیما

// lib/validations/project.ts
import { z } from "zod";

export const projectSchema = z.object({
  slug: z.string().min(2).regex(/^[a-z0-9-]+$/, "Slug must be kebab-case"),
  titleEn: z.string().min(1, "English title is required"),
  order: z.coerce.number().int().default(0), // مقادیر FormData همیشه string ان — coerce‌شون کن
});

۲. Server Action

"use server";

export type ActionState = {
  success: boolean;
  message?: string;
  fieldErrors?: Record<string, string[]>;
};

export async function createProject(
  _prev: ActionState,
  formData: FormData
): Promise<ActionState> {
  const parsed = projectSchema.safeParse({
    slug: formData.get("slug"),
    titleEn: formData.get("titleEn"),
    order: formData.get("order") ?? 0,
  });

  if (!parsed.success) {
    return {
      success: false,
      message: "لطفاً فیلدهای هایلایت‌شده رو درست کن.",
      fieldErrors: parsed.error.flatten().fieldErrors,
    };
  }

  await projectRepository.create(parsed.data);
  return { success: true };
}

۳. وصل کردنش به فرم

"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";

const INITIAL_STATE: ActionState = { success: false };

function SubmitButton() {
  const { pending } = useFormStatus(); // وضعیت pending نزدیک‌ترین <form> والد رو می‌خونه
  return <button disabled={pending}>{pending ? "در حال ذخیره…" : "ایجاد"}</button>;
}

export default function ProjectForm() {
  const [state, formAction] = useActionState(createProject, INITIAL_STATE);

  return (
    <form action={formAction}>
      <input name="titleEn" />
      {state.fieldErrors?.titleEn && <p>{state.fieldErrors.titleEn[0]}</p>}
      <SubmitButton />
    </form>
  );
}

یه نکته‌ی مهم: ری‌دایرکت

وسوسه‌انگیزه که آخر یه Server Action که با useActionState اجرا می‌شه، redirect() صدا بزنی — ولی این فعلاً توی Next.js قابل‌اعتماد نیست (اولین submit ممکنه بی‌سروصدا ری‌دایرکت نکنه). الگوی قابل‌اعتمادتر: از اکشن { success: true } برگردون، و ری‌دایرکت رو سمت کلاینت از یه useEffect که اون state رو نگاه می‌کنه انجام بده.

useEffect(() => {
  if (state.success) {
    router.push("/admin/projects");
    router.refresh(); // داده‌ی سرور صفحه‌ی مقصد رو دوباره می‌گیره
  }
}, [state.success, router]);

چرا این از یه فرم‌لایبرری سمت کلاینت اینجا بهتره

  • یه اسکیما، نه دوتا — بدون یه config جدای validation کلاینت که ممکنه از قوانین سرور جدا بیفته.

  • قبل از تموم شدن hydration هم کار می‌کنه، و بدون جاوااسکریپت به یه POST فرم واقعی تنزل پیدا می‌کنه.

  • بدون API route، بدون fetch، بدون سریالایز/دی‌سریالایز دستی JSON، بدون لوله‌کشی state لودینگ فراتر از useFormStatus.

این جایگزین چیزی مثل React Hook Form برای فرم‌های واقعاً پیچیده و بسیار تعاملی نیست (ویزاردهای چندمرحله‌ای با وابستگی سنگین سمت کلاینت) — ولی برای حالت خیلی رایجِ «این رکورد رو بساز/ویرایش کن»، کد کمتر، بخش‌های متحرک کمتر، و یه منبع حقیقت واحد برای validation‌ه.