useActionState với Zod trong Next.js 16: Hướng Dẫn Form Validation Toàn Diện

Hướng dẫn đầy đủ cách dùng useActionState và Zod trong Next.js 16 để xây dựng form an toàn với validation hai lớp, Progressive Enhancement và xử lý lỗi field-level.

useActionState + Zod Next.js 16: Form 2026

Form là phần thiết yếu của hầu hết ứng dụng Next.js — và thật lòng mà nói, cách chúng ta xử lý form đã thay đổi khá nhiều trong năm 2026. Với React 19 và Next.js 16, hook useActionState kết hợp Server Actions và Zod đã trở thành tiêu chuẩn vàng, thay thế hoàn toàn useFormState cũ. Trong bài này, mình sẽ hướng dẫn bạn cách xây dựng form an toàn với validation hai lớp (client + server), hỗ trợ Progressive Enhancement và hiển thị trạng thái loading mượt mà.

Bạn sẽ học được mẫu thiết kế đầy đủ, áp dụng được ngay vào dự án thực tế: từ định nghĩa schema Zod dùng chung cả hai phía, đến xử lý lỗi field-level, hiển thị thông báo thành công, và tích hợp với database. Tất cả đều dựa trên Next.js 16.2 và React 19 — phiên bản mới nhất tính đến tháng 4/2026.

Tại Sao useActionState Thay Thế useFormState trong Next.js 16

Khi nâng cấp lên Next.js 15+ và React 19, hook useFormState từ react-dom đã bị deprecated và được đổi tên thành useActionState, đồng thời chuyển vào package react. Đây không chỉ là chuyện đổi tên cho vui — API mới còn cung cấp giá trị pending tích hợp sẵn, nghĩa là bạn không cần phải dùng kèm với useFormStatus cho mọi trường hợp nữa.

Trong Next.js 16, có ba thay đổi quan trọng ảnh hưởng đến cách viết form:

  • useActionState trả về tuple ba phần tử [state, formAction, pending] thay vì hai như trước.
  • params trong route động giờ là Promise, ảnh hưởng đến cách viết Server Actions có dùng route params.
  • middleware.ts đã được đổi tên thành proxy.ts — điều này ảnh hưởng đến cách bảo vệ route chứa form admin.

Nếu bạn đang upgrade từ code cũ, hãy đọc thêm bài "Chuyển Đổi middleware.ts Sang proxy.ts" trên blog của tụi mình để cập nhật hệ thống xác thực cho đúng chuẩn.

Thiết Lập Dự Án: Next.js 16, React 19, Zod 3.x

Tạo dự án mới hoặc cập nhật package hiện có. Lưu ý version tối thiểu để tránh các lỗi tương thích khó chịu (mình đã từng debug vài tiếng vì vô tình dùng react@18 với useActionState — đừng lặp lại sai lầm đó):

npx create-next-app@latest nextjs-form-demo --typescript --app --tailwind
cd nextjs-form-demo
npm install zod
npm install -D @types/react@^19

Kiểm tra package.json để chắc chắn các version sau:

{
  "dependencies": {
    "next": "^16.2.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "zod": "^3.23.0"
  }
}

Định Nghĩa Schema Zod Dùng Chung Hai Phía

Đặt schema vào một file riêng để cả Server Action và Client Component đều có thể import được. Đây là mẫu DRY giúp tránh trùng lặp và đảm bảo validation đồng nhất ở cả hai phía.

// lib/schemas/contact.ts
import { z } from 'zod';

export const ContactFormSchema = z.object({
  name: z
    .string()
    .min(2, { message: 'Tên phải có ít nhất 2 ký tự' })
    .max(100, { message: 'Tên không được vượt quá 100 ký tự' }),
  email: z
    .string()
    .email({ message: 'Vui lòng nhập email hợp lệ' }),
  subject: z
    .string()
    .min(5, { message: 'Tiêu đề phải có ít nhất 5 ký tự' }),
  message: z
    .string()
    .min(10, { message: 'Nội dung phải có ít nhất 10 ký tự' })
    .max(2000, { message: 'Nội dung không được vượt quá 2000 ký tự' }),
});

export type ContactFormData = z.infer<typeof ContactFormSchema>;

export type FormState = {
  success: boolean;
  message: string;
  errors?: {
    name?: string[];
    email?: string[];
    subject?: string[];
    message?: string[];
  };
  fields?: Record<string, string>;
};

Việc định nghĩa FormState với cấu trúc nhất quán cực kỳ quan trọng. Mọi nhánh return trong Server Action phải trả về cùng shape này — nếu không, TypeScript sẽ la lên ngay, và useActionState sẽ hiển thị state không nhất quán (mình đã gặp bug này trên production một lần, không vui chút nào).

Viết Server Action với Validation

Server Action là một async function chạy trên server, được đánh dấu bằng directive 'use server'. Khi dùng kèm với useActionState, signature của action phải nhận prevState làm tham số đầu tiên và FormData làm tham số thứ hai. Đừng đảo ngược thứ tự nhé.

// app/actions/contact.ts
'use server';

import { ContactFormSchema, type FormState } from '@/lib/schemas/contact';

export async function submitContactForm(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const rawData = {
    name: formData.get('name') as string,
    email: formData.get('email') as string,
    subject: formData.get('subject') as string,
    message: formData.get('message') as string,
  };

  const validatedFields = ContactFormSchema.safeParse(rawData);

  if (!validatedFields.success) {
    return {
      success: false,
      message: 'Vui lòng kiểm tra lại các trường bị lỗi.',
      errors: validatedFields.error.flatten().fieldErrors,
      fields: rawData,
    };
  }

  try {
    await saveContactToDatabase(validatedFields.data);

    return {
      success: true,
      message: 'Tin nhắn đã được gửi thành công!',
    };
  } catch (error) {
    return {
      success: false,
      message: 'Đã xảy ra lỗi khi gửi tin nhắn. Vui lòng thử lại.',
      fields: rawData,
    };
  }
}

async function saveContactToDatabase(data: unknown) {
  await new Promise((resolve) => setTimeout(resolve, 1000));
}

Có hai điểm bạn cần đặc biệt chú ý trong code trên:

  1. Trả về fields khi validation thất bại — giúp form giữ lại giá trị người dùng đã nhập, tránh phải gõ lại từ đầu (cực kỳ khó chịu nếu đó là form dài).
  2. Dùng safeParse thay vì parse — tránh throw exception, kiểm soát flow rõ ràng hơn nhiều.

Xây Dựng Client Component với useActionState

Component form phải là Client Component để dùng được hook React. Đánh dấu file bằng 'use client' ở dòng đầu tiên:

// app/components/ContactForm.tsx
'use client';

import { useActionState } from 'react';
import { submitContactForm } from '@/app/actions/contact';
import type { FormState } from '@/lib/schemas/contact';

const initialState: FormState = {
  success: false,
  message: '',
};

export function ContactForm() {
  const [state, formAction, pending] = useActionState(
    submitContactForm,
    initialState
  );

  return (
    <form action={formAction} className="space-y-4 max-w-xl">
      <div>
        <label htmlFor="name" className="block text-sm font-medium">
          Họ và tên
        </label>
        <input
          id="name"
          name="name"
          type="text"
          defaultValue={state.fields?.name}
          aria-describedby="name-error"
          className="mt-1 w-full rounded border px-3 py-2"
        />
        {state.errors?.name && (
          <p id="name-error" className="text-red-600 text-sm mt-1">
            {state.errors.name[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input
          id="email"
          name="email"
          type="email"
          defaultValue={state.fields?.email}
          aria-describedby="email-error"
          className="mt-1 w-full rounded border px-3 py-2"
        />
        {state.errors?.email && (
          <p id="email-error" className="text-red-600 text-sm mt-1">
            {state.errors.email[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="subject" className="block text-sm font-medium">
          Tiêu đề
        </label>
        <input
          id="subject"
          name="subject"
          type="text"
          defaultValue={state.fields?.subject}
          className="mt-1 w-full rounded border px-3 py-2"
        />
        {state.errors?.subject && (
          <p className="text-red-600 text-sm mt-1">
            {state.errors.subject[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="message" className="block text-sm font-medium">
          Nội dung
        </label>
        <textarea
          id="message"
          name="message"
          rows={5}
          defaultValue={state.fields?.message}
          className="mt-1 w-full rounded border px-3 py-2"
        />
        {state.errors?.message && (
          <p className="text-red-600 text-sm mt-1">
            {state.errors.message[0]}
          </p>
        )}
      </div>

      <button
        type="submit"
        disabled={pending}
        className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
      >
        {pending ? 'Đang gửi...' : 'Gửi tin nhắn'}
      </button>

      {state.message && (
        <p
          className={
            state.success ? 'text-green-600' : 'text-red-600'
          }
          role="status"
          aria-live="polite"
        >
          {state.message}
        </p>
      )}
    </form>
  );
}

Để ý cách dùng defaultValue={state.fields?.name} — vì input là uncontrolled, nên dùng defaultValue để giữ giá trị sau khi submit thất bại. Nếu bạn dùng value, bạn buộc phải quản lý state bằng useState, làm phức tạp logic một cách không cần thiết.

Thêm Validation Phía Client Trước Khi Submit

Mặc dù validation server-side là bắt buộc về mặt bảo mật, validation client-side vẫn cải thiện UX rõ rệt — nó hiển thị lỗi ngay lập tức mà không cần round-trip lên server. Bạn có thể thực hiện điều này bằng cách dùng onSubmit với e.preventDefault():

'use client';

import { useActionState, useState } from 'react';
import { ContactFormSchema, type FormState } from '@/lib/schemas/contact';
import { submitContactForm } from '@/app/actions/contact';

export function ContactFormHybrid() {
  const [state, formAction, pending] = useActionState(
    submitContactForm,
    { success: false, message: '' } as FormState
  );
  const [clientErrors, setClientErrors] = useState<
    Record<string, string>
  >({});

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    const formData = new FormData(e.currentTarget);
    const data = Object.fromEntries(formData);

    const result = ContactFormSchema.safeParse(data);
    if (!result.success) {
      e.preventDefault();
      const errors: Record<string, string> = {};
      for (const issue of result.error.issues) {
        const path = issue.path[0] as string;
        if (!errors[path]) errors[path] = issue.message;
      }
      setClientErrors(errors);
      return;
    }
    setClientErrors({});
  };

  return (
    <form action={formAction} onSubmit={handleSubmit}>
      {/* fields tương tự ở trên */}
    </form>
  );
}

Mẫu này được gọi là defense-in-depth validation: client-side cho UX, server-side cho bảo mật. Quan trọng: đừng bao giờ chỉ tin tưởng vào client-side validation. Bất kỳ ai có DevTools đều có thể bypass nó trong vài giây.

Progressive Enhancement: Form Hoạt Động Ngay Cả Khi Tắt JavaScript

Một trong những tính năng mạnh nhất (và bị đánh giá thấp) của Server Actions là Progressive Enhancement. Form được khai báo với action={formAction} sẽ hoạt động ngay cả khi JavaScript bị tắt — trình duyệt sẽ submit theo cơ chế HTML truyền thống.

Tuy nhiên, để Progressive Enhancement hoạt động đầy đủ, bạn cần lưu ý:

  • Đừng dùng onSubmit với logic phức tạp ngăn chặn submit (validation có thể bị bypass khi JS tắt — server-side validation sẽ là chốt chặn cuối).
  • Dùng defaultValue thay vì controlled inputs để giữ giá trị sau full page reload.
  • Hiển thị thông báo lỗi/thành công bằng cách render từ state, không phải bằng alert() hay toast cần JS.

Hiển Thị Trạng Thái Pending với useFormStatus

Ờm, vậy useActionState đã trả về pending rồi, nhưng đôi khi bạn cần trạng thái pending ở component con (ví dụ submit button đặt trong component riêng). Đây chính là lúc useFormStatus phát huy tác dụng:

// app/components/SubmitButton.tsx
'use client';

import { useFormStatus } from 'react-dom';

export function SubmitButton({ label = 'Gửi' }: { label?: string }) {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      aria-disabled={pending}
      className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
    >
      {pending ? 'Đang xử lý...' : label}
    </button>
  );
}

Lưu ý quan trọng: useFormStatus chỉ hoạt động khi component được đặt bên trong thẻ <form>. Nếu đặt ở vị trí khác sẽ luôn nhận pending: false. Đây là lỗi mà rất nhiều người (kể cả mình lúc đầu) hay mắc phải.

Xử Lý File Upload với Server Actions

Form upload file là use case phổ biến mà nhiều tutorial vô tình bỏ qua. Đây là cách xử lý đúng cùng với Zod validation:

// lib/schemas/upload.ts
import { z } from 'zod';

const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];

export const FileUploadSchema = z.object({
  file: z
    .instanceof(File, { message: 'Vui lòng chọn file' })
    .refine((f) => f.size <= MAX_SIZE, 'File không được vượt quá 5MB')
    .refine(
      (f) => ACCEPTED_TYPES.includes(f.type),
      'Chỉ chấp nhận JPEG, PNG, WebP'
    ),
  caption: z.string().max(200).optional(),
});
// app/actions/upload.ts
'use server';

import { FileUploadSchema } from '@/lib/schemas/upload';
import { writeFile } from 'fs/promises';
import path from 'path';

export async function uploadFile(prevState: any, formData: FormData) {
  const validated = FileUploadSchema.safeParse({
    file: formData.get('file'),
    caption: formData.get('caption'),
  });

  if (!validated.success) {
    return {
      success: false,
      errors: validated.error.flatten().fieldErrors,
    };
  }

  const { file, caption } = validated.data;
  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);
  const filename = `${Date.now()}-${file.name}`;
  const filepath = path.join(process.cwd(), 'public/uploads', filename);

  await writeFile(filepath, buffer);

  return {
    success: true,
    message: 'Upload thành công',
    url: `/uploads/${filename}`,
  };
}

Trên client, bạn không cần thêm encType="multipart/form-data" với Server Actions — Next.js sẽ xử lý tự động — nhưng hãy đảm bảo input có type="file".

Tích Hợp với React Hook Form (Tùy Chọn Nâng Cao)

Khi dự án có nhiều form phức tạp, bạn có thể kết hợp React Hook Form với useActionState để có trải nghiệm controlled inputs tốt hơn:

'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useActionState } from 'react';
import { ContactFormSchema, type ContactFormData } from '@/lib/schemas/contact';
import { submitContactForm } from '@/app/actions/contact';

export function HookForm() {
  const [state, formAction, pending] = useActionState(
    submitContactForm,
    { success: false, message: '' }
  );

  const {
    register,
    formState: { errors },
    handleSubmit,
  } = useForm<ContactFormData>({
    resolver: zodResolver(ContactFormSchema),
  });

  return (
    <form
      action={formAction}
      onSubmit={(e) => {
        handleSubmit(() => {})(e);
      }}
    >
      <input {...register('email')} />
      {errors.email && <p>{errors.email.message}</p>}
      {/* ... */}
    </form>
  );
}

Cách này phù hợp khi bạn cần validation realtime lúc user gõ, nhưng đánh đổi là mất Progressive Enhancement. Tuỳ vào yêu cầu dự án mà cân nhắc — không có lựa chọn nào "đúng" tuyệt đối ở đây.

Bảo Mật: Những Điều Cần Lưu Ý với Server Actions

Server Actions chạy trên server, nhưng điều đó không có nghĩa bạn miễn nhiễm với mọi lo ngại bảo mật. Vẫn cần các biện pháp sau:

  • Luôn validate input trên server — không bao giờ tin tưởng client-side validation đơn lẻ.
  • Kiểm tra authentication và authorization trong action trước khi thực hiện thao tác nhạy cảm.
  • Rate limiting — Server Actions có thể bị spam y như API endpoint thường, đừng quên điều này.
  • CSRF protection — Next.js tự động bảo vệ thông qua origin check, nhưng cẩn thận khi expose action qua import.

Để tìm hiểu sâu hơn về các lỗ hổng và cách phòng thủ, tham khảo bài "Bảo Mật Server Actions trong Next.js: Từ Lỗ Hổng CVE Đến Phòng Thủ Nhiều Lớp" trên blog của tụi mình.

Debug và Khắc Phục Lỗi Thường Gặp

Lỗi: "useActionState is not a function"

Bạn đang import từ sai package. Phải import từ react, chứ không phải react-dom:

// SAI
import { useActionState } from 'react-dom';

// ĐÚNG
import { useActionState } from 'react';

Lỗi: Form mất giá trị sau khi submit thất bại

Đảm bảo Server Action trả về object có chứa fields, và Client Component dùng defaultValue={state.fields?.fieldName}. Đơn giản vậy thôi.

Lỗi: TypeScript phàn nàn về shape không khớp

Mọi nhánh return trong action phải trả về cùng shape. Khai báo type FormState rõ ràng và dùng nó làm return type của action — sẽ tiết kiệm cho bạn rất nhiều phút debug.

Lỗi: Pending state luôn là false

Nếu dùng useFormStatus, hãy đảm bảo component được đặt bên trong <form>. Nếu dùng useActionState, đảm bảo bạn destructure đúng phần tử thứ ba: const [state, action, pending] = useActionState(...).

Câu Hỏi Thường Gặp (FAQ)

Sự khác biệt giữa useFormState và useActionState là gì?

useFormState đã bị deprecated trong React 19 và được thay thế bằng useActionState. Có hai khác biệt chính: useActionState được import từ react (chứ không phải react-dom), và trả về tuple ba phần tử [state, action, pending] thay vì hai. Tham số pending mới giúp loại bỏ nhu cầu dùng useFormStatus trong rất nhiều trường hợp.

Có thể dùng useActionState mà không cần Server Actions không?

Có thể chứ. useActionState hoạt động với bất kỳ async function nào, không nhất thiết phải là Server Action. Tuy nhiên, sức mạnh thực sự của hook này thể hiện rõ nhất khi dùng cùng Server Actions, vì nó được tối ưu cho mô hình form-action của Next.js, hỗ trợ Progressive Enhancement và streaming response.

useActionState có hỗ trợ validation client-side không?

Bản thân useActionState không cung cấp validation. Bạn cần kết hợp với Zod hoặc thư viện tương tự. Khi dùng pattern action={formAction} đơn thuần, validation chỉ chạy trên server. Để có validation client-side, hãy dùng thêm onSubmit handler với e.preventDefault(), hoặc tích hợp React Hook Form với zodResolver.

Server Actions có an toàn để dùng trong production không?

Có. Tính đến tháng 4/2026, Server Actions đã stable từ Next.js 14 và đã được Vercel dùng trên production cho rất nhiều dự án lớn. Tuy nhiên cần áp dụng các biện pháp bảo mật tiêu chuẩn: validation bắt buộc trên server, authentication checks, rate limiting, và lưu ý không expose action nhạy cảm thông qua import sai cách.

Làm thế nào để hiển thị loading state trong nhiều form trên cùng một trang?

Dùng useFormStatus trong từng submit button của mỗi form — hook này tự động lấy trạng thái pending từ form gần nhất bao quanh nó. Ngoài ra, mỗi form có thể có useActionState riêng với pending độc lập, không can thiệp lẫn nhau.

Có nên dùng React Hook Form thay cho useActionState không?

Hai công cụ giải quyết vấn đề khác nhau và hoàn toàn có thể dùng kết hợp. useActionState tập trung vào tích hợp với Server Actions và Progressive Enhancement. Còn React Hook Form mạnh về controlled inputs, validation realtime khi gõ, và performance với form lớn. Nếu form của bạn đơn giản và cần Progressive Enhancement, dùng useActionState đơn lẻ là đủ. Nếu form phức tạp với nhiều field validation realtime, kết hợp cả hai sẽ cho bạn cái tốt nhất của hai thế giới.

Kết Luận

Pattern useActionState + Zod + Server Actions là cách tiếp cận hiện đại, an toàn và có khả năng mở rộng để xử lý form trong Next.js 16. Bằng cách đặt schema Zod ở vị trí dùng chung, validation hai phía, và dùng defaultValue để giữ Progressive Enhancement, bạn sẽ có form vừa nhanh, vừa an toàn, lại vừa accessible.

Bước tiếp theo? Thử áp dụng pattern này vào form đăng ký, đăng nhập, hoặc CRUD form trong dự án của bạn. Khi gặp validation phức tạp như cross-field validation hoặc async validation (ví dụ kiểm tra email đã tồn tại trong DB), refinesuperRefine của Zod sẽ là công cụ rất mạnh đáng để khám phá tiếp. Chúc bạn build form vui vẻ!

Về Tác Giả Editorial Team

Our team of expert writers and editors.