React 19 (stable 12/2024) gộp việc xử lý async và form vào một khái niệm mới — Actions — kèm hook use(), useOptimistic, và loạt dọn dẹp API như ref-as-prop (bỏ được forwardRef). Nếu bạn vẫn viết form với hàng đống useState cho pending và error, React 19 rút gọn đáng kể.
Bài này điểm qua những gì thực sự mới: cái nào đã stable, cái nào chỉ nên dùng qua framework, và khi nào nên áp dụng.
1. Actions: gom async và form vào một khái niệm
Trước React 19, một form submit async thường kéo theo ba useState: một cho isPending, một cho error, cộng logic bật/tắt thủ công. Nhìn quen mắt nhưng lặp lại ở mọi form:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// React 18: tự tay quản lý pending và error
function UpdateName() {
const [name, setName] = useState("");
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async () => {
setIsPending(true);
setError(null);
try {
await updateName(name);
} catch (err) {
setError(err.message);
} finally {
setIsPending(false);
}
};
return (
<div>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button onClick={handleSubmit} disabled={isPending}>
{isPending ? "Đang lưu..." : "Lưu"}
</button>
{error && <p>{error}</p>}
</div>
);
}React 19 gọi mẫu “một async function chạy bên trong một transition” là một Action. Khi Action bắt đầu, React tự đánh dấu pending; khi Action xong (hoặc lỗi), React tự thoát pending. Bạn không còn setIsPending bằng tay:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useState, useTransition } from "react";
function UpdateName() {
const [name, setName] = useState("");
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
// Hàm async bên trong startTransition chính là một Action
startTransition(async () => {
await updateName(name);
});
};
return (
<div>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button onClick={handleSubmit} disabled={isPending}>
{isPending ? "Đang lưu..." : "Lưu"}
</button>
</div>
);
}Điểm mới của React 19: startTransition giờ nhận cả async function. isPending tự bật khi Action chạy và tự tắt khi xong. Cơ chế nền — ưu tiên update, ngắt render — thuộc về concurrent rendering, đã đi sâu ở bài Concurrency Trong React 18: Transitions Và Suspense; Actions chỉ là lớp tiện dụng dựng trên nền đó.
2. useActionState: quản lý state của một action
Nếu Action cần trả về một giá trị (thường là message lỗi) và bạn muốn React giữ giá trị đó để hiển thị, dùng useActionState. Nó bọc luôn cả pending lẫn state trả về:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useActionState } from "react";
function ChangeName() {
const [error, submitAction, isPending] = useActionState(
async (previousState, formData) => {
const newName = formData.get("name");
const err = await updateName(newName);
if (err) {
return err; // giá trị trả về trở thành state mới
}
return null;
},
null // initial state
);
return (
<form action={submitAction}>
<input type="text" name="name" />
<button type="submit" disabled={isPending}>
Cập nhật
</button>
{error && <p>{error}</p>}
</form>
);
}Signature: useActionState(actionFn, initialState) trả về [state, formAction, isPending]. Trong đó actionFn nhận (previousState, formData) và giá trị nó return trở thành state cho lần render kế tiếp. Bạn truyền formAction thẳng vào prop action của <form>.
Lưu ý: đừng nhầm tên
Hook này từng có tên useFormState và nằm trong react-dom. React 19 đổi tên thành useActionState và chuyển vào react. Nếu gặp useFormState trong code hay tài liệu cũ, đó chính là cùng một thứ với tên khác.
3. form action và useFormStatus
Khi truyền một function vào prop action của <form>, React gọi action(formData) bên trong một transition — không cần e.preventDefault(), cũng không cần tự đọc từng input. Để một component con biết form đang submit, dùng useFormStatus:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useFormStatus } from "react-dom";
function SubmitButton() {
// Đọc trạng thái của <form> cha gần nhất
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Đang gửi..." : "Gửi"}
</button>
);
}
function SearchForm() {
async function search(formData) {
const query = formData.get("query");
await runSearch(query);
}
return (
<form action={search}>
<input name="query" />
<SubmitButton />
</form>
);
}Hai điểm dễ sai với useFormStatus:
- Nó nằm trong
react-dom, không phảireact. - Nó đọc form cha gần nhất phía trên, nên phải gọi từ một component render bên trong
<form>(nhưSubmitButton). Gọi trong chínhSearchForm— nơi render ra<form>— sẽ luôn chopending: false.
4. useOptimistic: cập nhật UI lạc quan
Optimistic UI là hiện kết quả ngay trước khi request thật xong, rồi tự chỉnh lại nếu cần. useOptimistic biến pattern đó thành một hook: bạn khai báo state lạc quan, cập nhật nó trong Action, và React tự revert khi Action kết thúc.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { useOptimistic } from "react";
function Thread({ messages, sendMessage }) {
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(state, newText) => [...state, { text: newText, sending: true }]
);
async function formAction(formData) {
const text = formData.get("message");
addOptimistic(text); // hiện ngay, đánh dấu "đang gửi"
await sendMessage(text); // request thật
}
return (
<>
{optimisticMessages.map((m, i) => (
<div key={i}>
{m.text} {m.sending && <small>(đang gửi...)</small>}
</div>
))}
<form action={formAction}>
<input name="message" />
<button type="submit">Gửi</button>
</form>
</>
);
}useOptimistic(state, updateFn) trả về [optimisticState, addOptimistic]. Trong lúc Action chạy, component đọc optimisticState (đã có tin nhắn mới với cờ sending). Khi Action kết thúc — dù thành công hay lỗi — React tự bỏ giá trị lạc quan và render lại theo state thật. Nhờ vậy nếu gửi lỗi, tin nhắn lạc quan đó tự biến mất mà bạn không phải dọn tay.
5. use(): đọc promise và context khi render
use() đọc giá trị của một resource — một Promise hoặc một Context — ngay trong lúc render. Điểm khác biệt lớn: không như mọi hook khác, use() được phép gọi trong if, trong vòng lặp, hoặc sau một early return.
1
2
3
4
5
6
7
8
9
10
import { use } from "react";
function Heading({ children }) {
if (children == null) {
return null;
}
// Gọi use() SAU early return — điều useContext không cho phép
const theme = use(ThemeContext);
return <h1 style={{ color: theme.color }}>{children}</h1>;
}Khi truyền một Promise vào use(), component sẽ suspend cho tới khi promise resolve, nên cần một <Suspense> bọc ngoài để hiện fallback:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { use, Suspense } from "react";
function Comments({ commentsPromise }) {
// use() đọc promise: component suspend tới khi resolve
const comments = use(commentsPromise);
return comments.map((c) => <p key={c.id}>{c.text}</p>);
}
function Page({ commentsPromise }) {
return (
<Suspense fallback={<p>Đang tải bình luận...</p>}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
);
}Nếu promise reject, error được đẩy lên Error Boundary gần nhất. Chi tiết cách Suspense hiển thị fallback nằm ở bài Concurrency Trong React 18.
Lưu ý: đừng tạo promise ngay trong render
use() không phải là chỗ để fetch. Nếu bạn tạo promise mới ngay trong thân component, mỗi lần render lại sinh một promise khác và React không cache được. Promise truyền vào use() nên đến từ một framework có hỗ trợ Suspense (như Next.js) hoặc từ một nguồn cache ổn định, rồi truyền xuống qua props/context.
6. ref là prop bình thường, forwardRef sắp được deprecate
Từ React 19, function component nhận ref như một prop bình thường — không phải bọc forwardRef nữa:
1
2
3
4
5
6
7
8
9
10
11
import { useRef } from "react";
// React 19: ref là một prop
function MyInput({ placeholder, ref }) {
return <input placeholder={placeholder} ref={ref} />;
}
function Form() {
const inputRef = useRef(null);
return <MyInput placeholder="Email" ref={inputRef} />;
}So với cách cũ, ngắn hơn hẳn:
1
2
3
4
5
6
// Trước React 19: phải bọc forwardRef
import { forwardRef } from "react";
const MyInput = forwardRef(function MyInput({ placeholder }, ref) {
return <input placeholder={placeholder} ref={ref} />;
});Lưu ý: forwardRef chưa bị gỡ
React 19 chưa xoá forwardRef — nó vẫn chạy bình thường. React chỉ dự kiến deprecate nó ở một phiên bản sau và có sẵn codemod để chuyển đổi. Vậy nên: component mới thì viết ref-as-prop; code cũ dùng forwardRef chưa cần đổi gấp.
Một thay đổi đi kèm: ref callback nay có thể trả về một cleanup function, chạy khi element bị gỡ — thay cho việc React gọi lại callback với null.
1
2
3
4
5
6
7
8
9
10
11
12
function Field() {
return (
<input
ref={(node) => {
const onFocus = () => {};
node.addEventListener("focus", onFocus);
// cleanup: chạy khi element unmount
return () => node.removeEventListener("focus", onFocus);
}}
/>
);
}Vì giá trị trả về giờ được hiểu là cleanup function, một arrow function kiểu ref={(node) => (el = node)} trả về ngầm giá trị gán sẽ bị TypeScript báo lỗi (và ở runtime React bỏ qua giá trị đó). Đổi sang dạng block để tránh: ref={(node) => { el = node; }}.
Nếu bạn dùng TypeScript và cần type prop ref cho component, xem thêm Typing Props Và Components Trong React TypeScript — bài đó đã bàn ref-as-prop khi nói về ComponentPropsWithRef.
7. Những dọn dẹp API khác
React 19 còn nhiều thay đổi nhỏ nhưng gỡ được kha khá thư viện phụ.
7.1. Document metadata tự hoist
Render <title>, <meta>, <link> ngay trong component; React tự đẩy (hoist) chúng lên <head>. Các trường hợp cơ bản không cần thư viện như react-helmet nữa:
1
2
3
4
5
6
7
8
9
10
11
function BlogPost({ post }) {
return (
<article>
<h1>{post.title}</h1>
<title>{post.title}</title>
<meta name="description" content={post.excerpt} />
<link rel="canonical" href={post.url} />
<p>{post.body}</p>
</article>
);
}7.2. Context dùng trực tiếp làm provider
Bạn render thẳng <Context> thay cho <Context.Provider>:
1
2
3
4
5
6
7
8
import { createContext } from "react";
const ThemeContext = createContext("light");
function App({ children }) {
// React 19: render thẳng <ThemeContext>, không cần .Provider
return <ThemeContext value="dark">{children}</ThemeContext>;
}<Context.Provider> vẫn dùng được nhưng sẽ được deprecate dần, nên component mới cứ dùng dạng ngắn.
7.3. preload và preinit
react-dom thêm nhóm API gợi ý trình duyệt tải sớm tài nguyên: preload, preinit, prefetchDNS, preconnect.
1
2
3
4
5
6
7
import { preload, preinit } from "react-dom";
function App() {
preinit("https://cdn.example.com/analytics.js", { as: "script" });
preload("https://cdn.example.com/font.woff2", { as: "font" });
return <Home />;
}Khác nhau ở mức độ: preload báo trình duyệt tải trước tài nguyên (chưa dùng ngay), còn preinit tải và áp dụng luôn — script được chạy, stylesheet được chèn vào document.
7.4. Hydration error rõ ràng hơn
Khi HTML từ server không khớp với client, React 19 in một message kèm diff giữa hai bên, thay vì đổ một loạt lỗi trùng lặp khó đọc như trước. Tìm chỗ mismatch nhanh hơn nhiều.
7.5. Server Components và Server Actions đã stable
React 19 đưa Server Components và Server Actions (hàm đánh dấu directive "use server") vào diện stable. Nhưng đây là tính năng cấp framework: bạn dùng chúng qua một framework hỗ trợ như Next.js (App Router), chứ không bật thẳng trong một app React thuần client. Bài này tập trung phần client-side; Server Components đủ lớn để xứng một bài riêng.
Điểm chung của React 19 là bớt boilerplate cho những việc lặp đi lặp lại — form, async, ref. Nếu đang ở React 18, cứ nâng cấp dần: ref-as-prop và Actions dùng được ngay mà không phá code cũ, còn Server Components thì để framework lo. Thử thay một form đầy useState-pending bằng useActionState, bạn sẽ thấy khác biệt liền.