Có một câu hỏi quay đi quay lại trong gần như mọi buổi review code React: "chỗ này có cần bọc useMemo không?". Rồi React Compiler lên 1.0 (tháng 10/2025), và tới Next.js 16 thì reactCompiler được promote từ experimental thành option chính thức. Nghe như câu trả lời cuối cùng cho nỗi đau memo hoá bằng tay.

Thực tế thì compiler không xoá sạch useMemo, và nó cũng không miễn phí. Dưới đây là những gì mình rút ra sau khi bật nó cho vài dự án Next.js.

Bật compiler là một dòng, nhưng không phải một dòng thần kỳ

Trước hết vẫn phải cài plugin, Next không tự kéo về:

npm install -D babel-plugin-react-compiler@latest

Rồi bật trong config. Ở Next.js 16 không còn experimental nữa:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  reactCompiler: true,
};

export default nextConfig;

Điều đáng chú ý: Next bật sẵn option này nhưng không bật mặc định. Lý do họ nói thẳng trong release note — compiler chạy qua Babel, nên thời gian dev và build sẽ tăng. Với dự án nhỏ thì không thấy gì, với repo vài nghìn component thì next build chậm hơn rõ rệt.

Hướng đi đang tốt lên: đội Oxc làm oxc-transform-react (biến thể Rust của compiler) và benchmark sơ bộ nhanh hơn Babel khoảng 10 lần; @vitejs/plugin-react từ v6.1 đã dùng được. Còn Next thì đang phối hợp với đội swc để có đường build nhanh hơn. Nói ngắn gọn: đừng lấy lý do "build chậm" để trì hoãn mãi, nhưng cũng đừng bật all-in rồi ngồi nhìn CI tăng gấp rưỡi.

Nên bật từng phần trước khi bật hết

Cách mình thích nhất là compilationMode: "annotation". Compiler sẽ nằm im, chỉ compile những component bạn đánh dấu bằng directive "use memo":

// next.config.ts
const nextConfig: NextConfig = {
  reactCompiler: {
    compilationMode: "annotation",
  },
};
"use memo";

type Item = { id: string; name: string; price: number };

export function ProductList({ items, keyword }: { items: Item[]; keyword: string }) {
  const filtered = items.filter((i) => i.name.includes(keyword));
  const total = filtered.reduce((sum, i) => sum + i.price, 0);

  return (
    <ul>
      {filtered.map((i) => (
        <li key={i.id}>
          {i.name} — {i.price}
        </li>
      ))}
      <li>Tổng: {total}</li>
    </ul>
  );
}

Không còn useMemo nào ở đây, mà component vẫn được memo hoá theo đúng phụ thuộc. Bật từng file theo directive giúp mình thấy được file nào compiler "chê", file nào chạy ổn — thay vì bật hết rồi truy một bug trong im lặng.

Cái bẫy thật: thư viện mutate nội bộ

Compiler dựa trên giả định component là hàm thuần, tham số không bị sửa. Vài thư viện phổ biến thì làm ngược lại: watch() của React Hook Form, TanStack Table, TanStack Virtual, MobX, một số thư viện chart/canvas. Với chúng, compiler hoặc bỏ qua component, hoặc tệ hơn là memo hoá sai chỗ khiến UI không cập nhật.

Cách xử lý là opt-out tại chỗ bằng "use no memo":

function FilterForm() {
  "use no memo";

  const { register, watch } = useForm<{ q: string }>();
  const q = watch("q"); // giá trị đổi theo cách compiler không theo dõi được

  return <input {...register("q")} placeholder={q} />;
}

Việc bắt buộc phải làm trước đó: nâng eslint-plugin-react-hooks lên v7+ và bật preset recommended. Plugin này giờ mang luôn các rule của compiler — purity, refs, immutability, preserve-manual-memoization, set-state-in-effect, incompatible-library. Chạy lint trước khi bật compiler là bước rủi ro thấp nhất mà lại dọn được nhiều thứ nhất.

Đừng tin badge ✨ trong DevTools

Bật compiler rồi, mở React DevTools thấy chữ "Memo ✨" cạnh tên component — nhiều người tưởng thế là xong. Badge đó chỉ xác nhận compiler đã chạy qua file, không nói gì về việc app nhanh hơn.

Muốn biết thật, dùng Profiler đo số lần render trước/sau, trên cùng một luồng thao tác. Và nhớ: compiler chỉ dọn phần "memo hoá thừa". Nếu state đặt sai chỗ khiến cả cây component render lại, hay context value bị tạo mới mỗi render, thì compiler không cứu được — đó vẫn là bug kiến trúc.

Vậy còn giữ useMemo khi nào?

Ba trường hợp mình vẫn giữ tay:

  • Phép tính thật đắt (parse dữ liệu lớn, build index) mà bạn muốn kiểm soát rõ ràng thay vì tin compiler.
  • Giá trị/đối tượng được truyền vào thư viện ngoài như một "hợp đồng" về identity — nơi việc memo hay không là một phần API, không phải chi tiết tối ưu.
  • Component đã "use no memo" vì thư viện nội bộ mutate, khi đó useMemo vẫn là công cụ duy nhất.

Ngoài ba trường hợp đó thì mặc định nên xoá. Code React sạch hơn, review bớt cãi nhau về dependency array, và compiler làm phần việc lặp lại.

Điều mình thấy đáng nói nhất: React Compiler không phải "tính năng tăng tốc" như marketing hay kể, nó là một hợp đồng — muốn dùng thì code của bạn phải đúng chuẩn. Repo nào còn nhiều chỗ vi phạm Rules of React thì bật compiler sẽ phơi hết ra, và đó lại là tin tốt.

📋 Phụ lục thuật ngữ

Thuật ngữ Giải thích ngắn
React Compiler Trình biên dịch của React, tự thêm memo hoá vào component/hook lúc build, không cần useMemo/useCallback thủ công
compilationMode: "annotation" Chế độ bật từng phần: chỉ file có directive "use memo" mới được compile
"use no memo" Directive opt-out cho một component/hàm cụ thể
Interior mutability Thư viện trả về object/hàm bị sửa nội bộ sau khi tạo — compiler không memo hoá an toàn được
Rules of React Bộ quy tắc (thuần, không sửa tham số, không đọc ref khi render) compiler dựa vào để tối ưu
oxc-transform-react Bản compiler chạy bằng Rust của Oxc, nhanh hơn babel-plugin-react-compiler khoảng 10 lần
Memo ✨ badge Dấu trong React DevTools báo component đã qua compiler — chỉ là kiểm tra kết nối, không phải bằng chứng hiệu năng

Xem các bài viết khác