Loại bỏ Boilerplate: Làm chủ React 19 Actions và useActionState

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Thực trạng lộn xộn của React Form truyền thống

Xử lý form trong React từng giống như một khoản “thuế” bạn phải trả cho mỗi tính năng mới. Nếu muốn xây dựng một biểu mẫu “Cập nhật hồ sơ” đơn giản, bạn không chỉ viết HTML; bạn phải xây dựng một state machine phức tạp. Bạn cần useState cho mỗi input, một biến toggle loading, một chuỗi error, và một hàm handleSubmit để can thiệp thủ công vào hành vi mặc định của trình duyệt.

Tôi nhớ đã từng kiểm tra một dashboard nơi một biểu mẫu đăng nhập duy nhất yêu cầu tới 45 dòng code boilerplate trước khi chạm đến câu lệnh return. Để giữ cho code gọn gàng, chúng ta thường tìm đến các thư viện nặng ký như Formik hoặc React Hook Form. Những công cụ này rất mạnh mẽ, nhưng chúng đi kèm với cái giá: tăng dung lượng bundle và tốn thời gian đào tạo cho nhân viên mới. Vấn đề thực sự không phải là thiếu công cụ, mà là React vốn không hiểu được vòng đời của một quá trình thay đổi dữ liệu (data mutation) bất đồng bộ một cách nguyên bản.

Tại sao chúng ta từng quá phức tạp hóa logic của Form

Sự bất cập này bắt nguồn từ một sự lệch pha cơ bản. React được thiết kế để đồng bộ state với UI. Tuy nhiên, việc gửi form là một transaction (giao dịch), không chỉ đơn thuần là thay đổi state. Trong kỷ nguyên trước React 19, framework này không có khái niệm tích hợp sẵn về một “Action” có thể kéo dài qua nhiều lần render.

Vì React không thể theo dõi khi nào một thao tác bất đồng bộ bắt đầu hoặc kết thúc, chúng ta phải tự làm điều đó. Điều này dẫn đến mô hình “State Soup” (mớ hỗn độn state) đáng sợ ám ảnh nhiều codebase:

const [data, setData] = useState(null);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState(null);

async function handleSubmit(e) {
  e.preventDefault();
  setIsPending(true);
  try {
    const result = await updateProfile(new FormData(e.target));
    setData(result);
  } catch (err) {
    setError(err);
  } finally {
    setIsPending(false);
  }
}

Mô hình này lặp lại hàng triệu lần trên web. Nó mỏng manh và dễ gây lỗi—quên reset trạng thái loading trong khối finally là một sai lầm kinh điển. Thẳng thắn mà nói, việc bảo trì nó ở quy mô lớn thực sự rất mệt mỏi.

Sự chuyển dịch: Tính năng Native đối đầu với Thư viện bên thứ ba

React 19 thay đổi cuộc chơi. Chúng ta không còn phải lựa chọn giữa việc viết hàng núi code thủ công hoặc import một thư viện nặng 15kb. Giờ đây chúng ta có một giải pháp trung gian nguyên bản (native) nhanh hơn, nhẹ hơn và dễ đọc hơn.

Sau khi áp dụng các mô hình React 19 vào môi trường production, sự khác biệt thấy rõ ngay lập tức. Đội ngũ của tôi đã loại bỏ được vài dependency liên quan đến form, giảm kích thước bundle gzipped khoảng 14.2kb. Dưới đây là bảng so sánh các cách tiếp cận:

  • Manual State: Kiểm soát hoàn toàn, nhưng bạn sẽ mất 30% thời gian để viết đi viết lại các khối try/catch/finally.
  • External Libraries: Tuyệt vời cho các schema có 50 input, nhưng chúng gây ra tình trạng “phụ thuộc nhà cung cấp” (vendor lock-in) và thêm gánh nặng không cần thiết cho các tác vụ đơn giản.
  • React 19 Actions: Không dependency. Nó tích hợp với Concurrent Mode và tự động xử lý các trạng thái pending.

Triển khai React 19 Actions: Mô hình sẵn sàng cho Production

React 19 giới thiệu “Actions”—các hàm sử dụng logic dựa trên transition để quản lý dữ liệu. Công cụ đắc lực ở đây là useActionState (mà bạn có thể đã thấy dưới tên useFormState trong các bản build thử nghiệm trước đó).

Hook này nhận vào một hàm action và một state khởi tạo. Nó trả về state hiện tại, một phiên bản đã được bọc (wrapped) của action để sử dụng trong form, và một biến boolean isPending đơn giản.

Mô hình Form hiện đại

Hãy refactor một form tiêu chuẩn sang cách triển khai React 19 gọn gàng. Đầu tiên, chúng ta định nghĩa logic cho việc cập nhật:

// actions.js
export async function updateUsername(prevState, formData) {
  const newName = formData.get("username");
  
  // Giả lập độ trễ API 1.5 giây
  await new Promise(res => setTimeout(res, 1500));

  if (newName === "admin") {
    return { error: "Tên này đã được đặt trước", success: false };
  }

  return { error: null, success: true, name: newName };
}

Tiếp theo, chúng ta đưa nó vào component. Lưu ý rằng chúng ta không cần onSubmit hay e.preventDefault() nữa:

import { useActionState } from "react";
import { updateUsername } from "./actions";

function ProfileForm() {
  const [state, formAction, isPending] = useActionState(updateUsername, {
    error: null,
    success: false,
  });

  return (
    <form action={formAction}>
      <input type="text" name="username" disabled={isPending} />
      <button type="submit" disabled={isPending}>
        {isPending ? "Đang lưu..." : "Cập nhật tên"}
      </button>
      
      {state.error && <p className="error">{state.error}</p>}
      {state.success && <p className="success">Đã đổi thành {state.name}!</p>}
    </form>
  );
}

Giải quyết cập nhật UI phân cấp sâu với useFormStatus

Hãy nhớ lại lần cuối bạn phải vô hiệu hóa một nút submit nằm sâu ba tầng trong layout. Việc truyền isPending qua props là một cơn ác mộng. React 19 cung cấp useFormStatus để giải quyết vấn đề này. Nó hoạt động như một Context provider chuyên dụng tự động bao quanh mỗi <form>.

import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending } = useFormStatus();
  
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Đang xử lý..." : "Lưu thay đổi"}
    </button>
  );
}

Component SubmitButton giờ đây có thể phát hiện liệu form cha của nó có đang được gửi hay không, bất kể có bao nhiêu component nằm giữa chúng.

Khi nào bạn nên sử dụng tính năng này?

Sau nửa năm sử dụng các tính năng này trong môi trường thực tế, tôi khuyên bạn nên sử dụng Actions nguyên bản cho 90% yêu cầu của mình. API formData có khả năng đáng kinh ngạc. Nó xử lý việc upload file, checkbox và multi-select mà không cần cấu hình thêm.

Tuy nhiên, hãy ghi nhớ ba điểm sau:

  1. Validation: Đối với các kiểm tra cơ bản, hãy dùng các thuộc tính HTML5 như required hoặc pattern. Đối với các schema phức tạp, nhiều trang, việc kết hợp Actions với một thư viện như Zod là lựa chọn tối ưu nhất.
  2. Optimistic UI: Nếu bạn muốn UI cập nhật ngay tích tắc khi người dùng nhấn “Submit”, hãy kết hợp useActionState với hook useOptimistic.
  3. Progressive Enhancement: Actions hoạt động ngay cả trước khi bundle JavaScript tải xong. Đây là một điểm cộng lớn cho người dùng sử dụng kết nối 3G chậm hoặc thiết bị cũ.

Lời kết

Sự chuyển dịch sang Actions thể hiện một bước đi từ việc quản lý tỉ mỉ từng phím nhấn sang tập trung vào ý định của người dùng. Bằng cách áp dụng useActionStateuseFormStatus, chúng ta viết ít code hơn, giảm dung lượng tải xuống và xây dựng các ứng dụng bền bỉ hơn. Nếu bạn đang bắt đầu một dự án mới trên React 19, hãy thử cách làm nguyên bản này trước. Bạn có thể thấy rằng những thư viện form bên thứ ba không còn cần thiết như trước đây nữa.

Share: