React Compiler: tạm biệt useMemo và useCallback thủ công?

1. Tổng quan

Nhiều dự án React có những đoạn useMemo, useCallback và React.memo rải khắp codebase. Mục đích là tránh re-render thừa, nhưng cái giá là code dài hơn và dễ sai dependency. React Compiler chuyển việc memoization này sang lúc build: nó phân tích component và tự chèn cache ở những chỗ cần thiết.

Bài này giải thích React Compiler là gì và vì sao nó được quan tâm. Bạn sẽ biết cách bật compiler trong một dự án Vite, cách chuyển đổi dần từng phần, cách viết test cho hành vi memoization, và những trường hợp không nên dùng.

2. Vấn đề với memoization thủ công

Xét một trang danh sách đơn hàng có ô lọc và bộ đếm không liên quan:

import { useState, useMemo, useCallback, memo } from "react";

type Order = { id: number; customer: string; total: number };

const OrderList = memo(function OrderList({
  orders,
  onSelect,
}: {
  orders: Order[];
  onSelect: (id: number) => void;
}) {
  return (
    <ul>
      {orders.map((o) => (
        <li key={o.id} onClick={() => onSelect(o.id)}>
          {o.customer}: {o.total}
        </li>
      ))}
    </ul>
  );
});

export function OrdersPage({ orders }: { orders: Order[] }) {
  const [keyword, setKeyword] = useState("");
  const [clicks, setClicks] = useState(0);

  const filtered = useMemo(
    () => orders.filter((o) => o.customer.includes(keyword)),
    [orders, keyword]
  );

  const handleSelect = useCallback((id: number) => {
    console.log("Selected order", id);
  }, []);

  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <button onClick={() => setClicks((c) => c + 1)}>Clicks: {clicks}</button>
      <OrderList orders={filtered} onSelect={handleSelect} />
    </>
  );
}

Đoạn code chạy đúng, nhưng có ba điểm dễ gãy:

  • Nếu bỏ memo ở OrderList, cả useMemo lẫn useCallback gần như vô dụng.
  • Nếu quên keyword trong mảng dependency, danh sách lọc sẽ hiển thị dữ liệu cũ.
  • Chỉ cần một prop mới là object literal như style={{ color: "red" }}, toàn bộ công sức memo bị phá vỡ.

Memoization thủ công là một hệ thống ngầm định. Ai sửa component cũng phải hiểu nó, và lỗi thường không lộ ra cho tới khi có người profile hiệu năng.

3. React Compiler là gì

React Compiler là một công cụ chạy lúc build, thường dưới dạng Babel plugin. Nó đọc code component và hook, hiểu luồng dữ liệu, rồi sinh ra code có cache cho:

  • Giá trị tính toán: tương đương useMemo.
  • Hàm: tương đương useCallback.
  • JSX element: tương đương việc bọc child bằng memo. Nếu input của một element không đổi, compiler tái sử dụng element cũ và React bỏ qua việc render lại subtree đó.

Đoạn dưới đây minh hoạ ý tưởng của code sau khi compile. Đây là phiên bản đã đơn giản hoá, không phải output thật:

function OrdersPage({ orders }) {
  const $ = useMemoCache(6); // mảng cache gắn với instance component
  const [keyword, setKeyword] = useState("");

  let filtered;
  if ($[0] !== orders || $[1] !== keyword) {
    filtered = orders.filter((o) => o.customer.includes(keyword));
    $[0] = orders;
    $[1] = keyword;
    $[2] = filtered;
  } else {
    filtered = $[2];
  }
  // ... tương tự cho handleSelect và các JSX element
}

Compiler so sánh từng input với giá trị lần render trước. Chỉ khi có input thay đổi, nó mới tính lại. So với memo thủ công, có hai khác biệt quan trọng:

  1. Chi tiết hơn: compiler có thể memoize cả những đoạn nằm sau câu lệnh điều kiện, điều mà useMemo không làm được vì hook không được gọi có điều kiện.
  2. Không có mảng dependency viết tay: dependency được suy ra từ code, nên không thể “quên”.

4. Vì sao React Compiler đang được quan tâm

Có vài lý do khiến chủ đề này được nhắc tới nhiều:

  • Giảm boilerplate: code trở về dạng “React thuần”, dễ đọc và dễ review.
  • Hiệu năng mặc định tốt hơn: đội ngũ không cần chuyên gia profile mới tránh được re-render thừa.
  • Ép tuân thủ Rules of React: compiler chỉ tối ưu được code “đúng luật”. Vì vậy nó kéo theo một bộ lint giúp phát hiện code vi phạm, kể cả khi bạn chưa bật compiler.
  • Tương thích dần: có thể bật cho một thư mục hoặc một component, không cần viết lại cả ứng dụng.

Lưu ý: Compiler không làm code chạy nhanh hơn ở mọi nơi. Nó giảm công việc render lặp lại. Nếu bottleneck của bạn là network, bundle size hay một vòng lặp nặng chạy mỗi lần input đổi, compiler không giải quyết được.

5. Bật React Compiler trong dự án

5.1. Cài đặt với Vite

Cài plugin và bộ lint. Tên package có thể thay đổi theo thời gian, nên bạn hãy kiểm tra tài liệu chính thức của React cho phiên bản đang dùng.

npm install -D babel-plugin-react-compiler eslint-plugin-react-hooks

Thêm plugin vào cấu hình Vite:

// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [["babel-plugin-react-compiler", {}]],
      },
    }),
  ],
});

Compiler nên chạy đầu tiên trong chuỗi Babel plugin, vì nó cần đọc code gốc trước khi plugin khác biến đổi.

Nếu dự án còn dùng React đời cũ hơn, compiler cần thêm một runtime package và tùy chọn target để sinh code tương thích. Phần này phụ thuộc phiên bản, hãy tra tài liệu trước khi cấu hình.

Với các framework như Next.js hay Expo, thường có cờ cấu hình riêng để bật compiler. Tên cờ đã từng thay đổi giữa các phiên bản, nên bạn hãy đọc release note của framework thay vì sao chép cấu hình cũ.

5.2. Kiểm tra compiler đã hoạt động

Mở React DevTools. Component được compiler tối ưu sẽ có nhãn kiểu “Memo ✨” cạnh tên. Không có nhãn đó nghĩa là compiler đã bỏ qua component, thường do vi phạm quy tắc ở mục 6.

5.3. Bật lint

// eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";

export default [
  reactHooks.configs.recommended,
];

Lint báo trước những đoạn code mà compiler sẽ bỏ qua hoặc có thể chạy sai. Bạn nên bật lint trước compiler và sửa hết cảnh báo.

6. Rules of React: điều kiện để compiler làm việc

Compiler giả định component là hàm thuần khi render. Ví dụ sau vi phạm điều đó:

export function CartSummary({ items }: { items: { price: number }[] }) {
  items.sort((a, b) => b.price - a.price); // mutate prop!
  const total = items.reduce((sum, i) => sum + i.price, 0);
  return <p>Top item: {items[0]?.price}, total: {total}</p>;
}

items.sort sửa trực tiếp mảng được truyền vào. Khi có cache, compiler có thể không nhận ra items đã “đổi” vì tham chiếu vẫn giữ nguyên, và UI hiển thị sai. Lint sẽ cảnh báo kiểu:

error  Mutating a value returned from a function whose return value should not be mutated  react-hooks/...

Thông báo cụ thể tùy phiên bản plugin. Cách sửa là tạo bản sao:

const sorted = [...items].sort((a, b) => b.price - a.price);

Các vi phạm hay gặp:

  • Mutate props, state hoặc giá trị trả về từ hook.
  • Đọc hoặc ghi ref.current trong lúc render.
  • Gọi hook có điều kiện hoặc trong vòng lặp.
  • Side effect trực tiếp trong thân component, ví dụ ghi vào biến module hoặc gọi API.

Khi gặp code như vậy, compiler thường bỏ qua component đó thay vì phá hỏng nó. Đây là hành vi an toàn, nhưng bạn sẽ mất phần tối ưu.

7. Chuyển đổi dần trong dự án lớn

7.1. Chế độ opt-in

Với codebase lớn, bạn có thể chỉ compile những component được đánh dấu:

// vite.config.js (trích)
plugins: [["babel-plugin-react-compiler", { compilationMode: "annotation" }]],
export function InvoiceTable({ invoices }: { invoices: Invoice[] }) {
  "use memo";
  // ... component này được compile
}

Directive "use memo" ở đầu thân hàm báo cho compiler xử lý component này. Các component khác giữ nguyên.

7.2. Tạm loại trừ một component

Khi đã bật toàn cục mà một component gây lỗi, bạn có thể tạm tắt compiler cho riêng nó:

export function LegacyChart(props: ChartProps) {
  "use no memo";
  // ... code cũ dùng thư viện mutate dữ liệu
}

Lưu ý: "use no memo" là lối thoát tạm thời. Bạn nên ghi chú lý do và tạo task để sửa, đừng để nó trở thành mặc định.

7.3. Còn các useMemo cũ thì sao?

Compiler làm việc được với code đã có useMemo/useCallback, nên bạn không cần xoá ngay. Xoá hàng loạt có thể thay đổi hành vi tinh tế, nhất là khi giá trị được dùng làm dependency của useEffect. Hãy xoá dần, kèm test. Với component mới, cứ viết code thuần và để compiler lo.

8. Viết test cho hành vi memoization

Bạn có thể kiểm tra rằng child không re-render khi state không liên quan thay đổi. Ví dụ dưới dùng Vitest và React Testing Library, chạy với cùng cấu hình Vite đã bật compiler:

npm install -D vitest @testing-library/react @testing-library/user-event jsdom
// OrdersPage.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";

const renderSpy = vi.fn();

function OrderList({ orders }: { orders: string[] }) {
  renderSpy();
  return <ul>{orders.map((o) => <li key={o}>{o}</li>)}</ul>;
}

function OrdersPage() {
  const [clicks, setClicks] = useState(0);
  const orders = ["Alice", "Bob"]; // không dùng useMemo
  return (
    <>
      <button onClick={() => setClicks((c) => c + 1)}>Clicks: {clicks}</button>
      <OrderList orders={orders} />
    </>
  );
}

describe("OrdersPage với React Compiler", () => {
  it("không render lại OrderList khi chỉ bộ đếm thay đổi", async () => {
    render(<OrdersPage />);
    await userEvent.click(screen.getByRole("button"));
    await userEvent.click(screen.getByRole("button"));

    expect(screen.getByRole("button")).toHaveTextContent("Clicks: 2");
    expect(renderSpy).toHaveBeenCalledTimes(1);
  });
});

Giải thích:

  • orders là mảng literal, không bọc useMemo. Không có compiler, mảng mới được tạo mỗi lần render và OrderList render lại.
  • Có compiler, cả mảng orders lẫn element <OrderList /> đều được cache vì chúng không phụ thuộc clicks.
  • renderSpy chỉ được gọi một lần, ở lần render đầu.

Kết quả mong đợi:

✓ OrdersPage với React Compiler > không render lại OrderList khi chỉ bộ đếm thay đổi
Test Files  1 passed (1)

Nếu tắt compiler, assertion cuối sẽ thất bại với giá trị là 3. Matcher toHaveTextContent cần thêm @testing-library/jest-dom trong file setup.

Lưu ý: Gọi spy trong thân component là side effect. Cách này chỉ chấp nhận được trong test để đo số lần render. Đừng áp dụng trong code production.

9. Khi nào KHÔNG nên dùng (hoặc nên cẩn trọng)

  • Codebase vi phạm Rules of React nhiều: compiler sẽ bỏ qua phần lớn component, hoặc tệ hơn là để lộ bug vốn đang bị che giấu. Hãy sửa theo lint trước.
  • Thư viện dựa trên mutation: một số thư viện state hoặc form thay đổi object tại chỗ và mong component đọc giá trị mới mỗi lần render. Với những component dùng chúng, có thể cần "use no memo".
  • Pipeline build đặc thù: nếu bạn không dùng Babel, hoặc toolchain không hỗ trợ plugin, việc tích hợp sẽ tốn công. Hãy kiểm tra hỗ trợ của bundler trước.
  • Ứng dụng nhỏ, ít tương tác: lợi ích có thể không đáng kể so với thời gian build tăng thêm và độ phức tạp khi debug.
  • Cần đảm bảo tham chiếu ổn định cho effect: compiler memoize để tối ưu chứ không hứa hẹn ngữ nghĩa. Nếu logic đúng/sai phụ thuộc vào việc một giá trị giữ nguyên tham chiếu, ví dụ để effect không chạy lại, hãy giữ useMemo/useCallback tường minh để thể hiện ý định.

Ngoài ra, khi debug, code chạy trong trình duyệt khác code bạn viết. Source map giúp ích, nhưng bạn nên làm quen với DevTools và công cụ playground của compiler để xem output khi cần.

10. Kết luận

React Compiler không xoá sổ useMemo và useCallback, nhưng biến chúng từ công cụ dùng hằng ngày thành lối thoát cho trường hợp đặc biệt. Điều kiện tiên quyết là code phải tuân thủ Rules of React. Vì vậy, bước đầu tiên luôn là bật lint và sửa vi phạm.

Lộ trình gợi ý: bật lint, sửa cảnh báo, bật compiler ở chế độ annotation cho vài component, viết test đo số lần render, rồi mở rộng dần. Làm như vậy, bạn có được hiệu năng tốt hơn mà không cần mảng dependency viết tay.

reactreact-compilerperformancefrontendtech-trends