Hồi mới xài Next.js App Router, mình viết một form đăng bài cỏn con mà trong component có tới năm cái useState: title, đang gửi, lỗi, thành công, rồi thêm một cái để disable nút. Chưa hết, còn một useEffect để reset form sau khi gửi xong. Chạy thì vẫn chạy, nhưng mỗi lần thêm một field là lại một vòng copy-paste.
React 19 gom gần hết đống đó vào hai hook: useActionState lo state của form, useOptimistic lo cảm giác "bấm là thấy ngay". Dưới đây là cách mình dùng thật, kèm mấy chỗ mình đã vấp.
Form kiểu cũ: cái gì cũng phải tự quản
Cách cũ không sai, chỉ là mọi thứ đều thủ công. onSubmit phải preventDefault, set loading true, try/catch, set error, rồi nhớ set loading false trong finally. Quên một nhánh là nút kẹt ở trạng thái "Đang lưu..." vĩnh viễn.
Điểm yếu thật sự nằm ở chỗ: state gửi form là một máy trạng thái, nhưng mình lại chia nó thành nhiều biến rời rạc. loading là boolean, error là string, ok là boolean — không có gì bắt ba biến đó phải nhất quán với nhau.
useActionState — gói state, pending và lỗi vào một chỗ
Action nhận (state trước đó, formData) và trả về state mới. Với Server Action thì đặt ở file riêng:
// app/actions.ts
"use server";
export type FormState = { ok: boolean; error?: string };
export async function createPost(
_prev: FormState,
formData: FormData,
): Promise<FormState> {
const title = String(formData.get("title") ?? "").trim();
if (title.length < 3) return { ok: false, error: "Tiêu đề quá ngắn" };
await db.post.create({ data: { title } });
return { ok: true };
}
Phía client, thay vì 5 useState chỉ còn đúng một dòng:
// app/post-form.tsx
"use client";
import { useActionState } from "react";
import { createPost, type FormState } from "./actions";
const initial: FormState = { ok: false };
export function PostForm() {
const [state, formAction, isPending] = useActionState(createPost, initial);
return (
<form action={formAction}>
<input name="title" />
<button disabled={isPending}>
{isPending ? "Đang lưu..." : "Lưu"}
</button>
{state.error && <p className="text-red-500">{state.error}</p>}
</form>
);
}
Ba thứ trong một hook: state là kết quả action trả về, formAction gắn thẳng vào action của form, isPending cho biết action đang chạy. Không cần preventDefault, không cần try/catch, không cần tự set loading — React quản hết.
Điểm mình thích nhất: state trả về là một object có kiểu, nên không có chuyện error hiện cùng lúc với ok. TypeScript ép mình nghĩ về các trạng thái có thể xảy ra, thay vì rải boolean khắp nơi.
useFormStatus khi nút nằm sâu trong cây
isPending chỉ dùng được ở component gọi hook. Nếu nút submit nằm trong một component con (thường là vậy, để tái sử dụng), dùng useFormStatus:
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Đang lưu..." : "Lưu"}</button>;
}
Bẫy: useFormStatus phải nằm trong component được render bên trong <form>. Đặt cùng component với thẻ <form> là pending luôn false — im lặng, không báo lỗi, chỉ là nút không bao giờ disable. Mình mất cả buổi chiều vì quả này.
useOptimistic — UI trả lời trước server
Form gửi xong trong 200ms thì isPending đủ dùng. Nhưng bình luận, thả tim, tick todo mà đợi server trả lời mới đổi giao diện thì cảm giác ì ngay.
useOptimistic cho mình hiện kết quả "đoán trước" trong lúc chờ:
"use client";
import { useOptimistic, useRef } from "react";
import { addComment } from "./actions";
type Comment = { id: string; body: string; pending?: boolean };
export function CommentList({
postId,
comments,
}: {
postId: string;
comments: Comment[];
}) {
const [optimistic, addOptimistic] = useOptimistic(
comments,
(current: Comment[], next: Comment) => [...current, next],
);
const formRef = useRef<HTMLFormElement>(null);
async function send(formData: FormData) {
const body = String(formData.get("body") ?? "");
addOptimistic({ id: `temp-${Date.now()}`, body, pending: true });
formRef.current?.reset();
await addComment(postId, body);
}
return (
<>
{optimistic.map((c) => (
<div key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
{c.body}
</div>
))}
<form ref={formRef} action={send}>
<input name="body" />
<button>Gửi</button>
</form>
</>
);
}
Comment mới hiện ngay với opacity 0.5. Khi action xong, comments thật từ server đè lên và mọi thứ khớp lại. Nếu action lỗi, React tự bỏ giá trị đoán — mình không phải viết code rollback.
Mấy cái bẫy mình đã vấp
addOptimisticphải gọi trong action hoặc transition. Gọi trong mộtonClickthường thì React cảnh báo và giá trị đoán không hiện. Cách sạch nhất là để hàmsendlàm action của<form>, như ví dụ trên.- Giá trị đoán chỉ sống trong lúc action chạy. Server trả lời nhanh quá thì bạn không kịp thấy gì — đừng lo, đó là điều tốt.
- Server Action chỉ nhận tham số tuần tự hoá được. Truyền
Datehay class instance vào là lỗi lúc runtime, không phải lúc compile.FormData, string, number, array, object thuần thì ổn. - Reset form sau khi gửi thành công vẫn phải làm tay (
formRef.current?.reset()), hook không tự reset giúp bạn.
Khi nào chưa cần
Nếu form chỉ có một input, xử lý ngay ở client, không gọi server (ví dụ ô search filter danh sách), thì useState bình thường vẫn gọn hơn. useActionState đáng dùng khi có bước bất đồng bộ và bạn muốn một nguồn sự thật duy nhất cho trạng thái form.
Mình cũng đã kể ranh giới server/client trong bài React Server Components — 'use client' đặt ở đâu là đúng? — hai hook này sống phía client, còn action thì ở phía server.
Kết
useActionState và useOptimistic không phải để code ngắn cho oai. Chúng gom trạng thái form về một chỗ có kiểu, và cho phép giao diện trả lời trước khi mạng kịp trả lời. Hai hook, một mớ useState biến mất, và mấy bug kiểu "nút kẹt loading" cũng biến mất theo.
📋 Phụ lục thuật ngữ
- Action — hàm bất đồng bộ gắn vào
<form action={...}>; React tự gọi và tự quản trạng thái pending. - Server Action — action chạy trên server, đánh dấu bằng
"use server", tham số phải tuần tự hoá được. - useActionState — hook trả về
[state, formAction, isPending], gói state form và trạng thái đang chạy. - useFormStatus — hook đọc trạng thái
pendingcủa<form>gần nhất; phải nằm trong component con của form. - useOptimistic — hook giữ giá trị "đoán trước", hiện kết quả tức thì trong lúc action chạy rồi tự khớp lại khi server trả lời.
- Transition — cập nhật không chặn UI; action luôn chạy trong một transition, nên giá trị đoán chỉ tồn tại trong khoảng thời gian đó.
- Progressive enhancement — form vẫn gửi được khi JavaScript chưa tải xong, nhờ cơ chế gửi form mặc định của trình duyệt.