React Compiler: Goodbye to Manual useMemo and useCallback?
1. Overview
Many React projects have useMemo, useCallback, and React.memo scattered throughout the codebase. The goal is to prevent unnecessary re-renders, but the cost is longer code and easier-to-make mistakes with dependencies. React Compiler moves memoization to build time: it analyzes components and automatically inserts caching where needed.
This article explains what React Compiler is and why it’s getting attention. You’ll learn how to enable the compiler in a Vite project, how to gradually migrate piece by piece, how to write tests for memoization behavior, and which cases you should avoid.
2. The Problem with Manual Memoization
Consider an order list page with a filter input and an unrelated counter:
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} />
</>
);
}
The code works correctly, but has three fragile points:
- If you remove
memofromOrderList, bothuseMemoanduseCallbackbecome nearly useless. - If you forget
keywordin the dependency array, the filtered list will display stale data. - Adding just one new prop as an object literal like
style={{ color: "red" }}breaks all the memoization effort.
Manual memoization is an implicit system. Anyone modifying the component must understand it, and bugs usually don’t surface until someone profiles performance.
3. What is React Compiler
React Compiler is a build-time tool, usually in the form of a Babel plugin. It reads component and hook code, understands the data flow, then generates code with caching for:
- Computed values: equivalent to
useMemo. - Functions: equivalent to
useCallback. - JSX elements: equivalent to wrapping children with
memo. If an element’s inputs don’t change, the compiler reuses the old element and React skips re-rendering that subtree.
The code below illustrates the concept after compilation. This is a simplified version, not the actual output:
function OrdersPage({ orders }) {
const $ = useMemoCache(6); // cache array attached to component instance
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];
}
// ... similar for handleSelect and JSX elements
}
The compiler compares each input with the value from the previous render. It only recalculates when inputs change. Compared to manual memo, there are two important differences:
- More fine-grained: the compiler can memoize even code that appears after conditional statements, something
useMemocan’t do since hooks can’t be called conditionally. - No manual dependency arrays: dependencies are inferred from the code, so you can’t “forget” them.
4. Why React Compiler is Getting Attention
There are several reasons this topic is being discussed widely:
- Reduced boilerplate: code returns to “pure React” form, easier to read and review.
- Better default performance: teams don’t need performance experts to avoid unnecessary re-renders.
- Enforces Rules of React: the compiler can only optimize “correct” code. As a result, it comes with a linting tool that catches violations, even before enabling the compiler.
- Gradual adoption: you can enable it for one folder or component without rewriting the entire app.
Note: The compiler doesn’t make code faster everywhere. It reduces repeated render work. If your bottleneck is network, bundle size, or a heavy loop running every input change, the compiler won’t help.
5. Enabling React Compiler in Your Project
5.1. Installation with Vite
Install the plugin and linter. The package name may change over time, so check React’s official documentation for your version.
npm install -D babel-plugin-react-compiler eslint-plugin-react-hooks
Add the plugin to your Vite config:
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
react({
babel: {
plugins: [["babel-plugin-react-compiler", {}]],
},
}),
],
});
The compiler should run first in the Babel plugin chain, since it needs to read the original code before other plugins transform it.
If your project uses an older version of React, the compiler needs an additional runtime package and a target option to generate compatible code. This depends on the version, so check the documentation before configuring.
For frameworks like Next.js or Expo, there are usually dedicated config flags to enable the compiler. The flag names have changed between versions, so read the framework’s release notes instead of copying old configs.
5.2. Verify the Compiler is Working
Open React DevTools. Components optimized by the compiler will have a label like “Memo ✨” next to the name. No label means the compiler skipped that component, usually due to rule violations in section 6.
5.3. Enable Linting
// eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";
export default [
reactHooks.configs.recommended,
];
Linting warns about code that the compiler will skip or run incorrectly. You should enable linting before the compiler and fix all warnings.
6. Rules of React: Conditions for the Compiler to Work
The compiler assumes components are pure functions during rendering. The following example violates this:
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 directly modifies the incoming array. With caching, the compiler might not realize items has changed because the reference stays the same, and the UI displays incorrectly. Linting will warn something like:
error Mutating a value returned from a function whose return value should not be mutated react-hooks/...
The exact message depends on the plugin version. The fix is to create a copy:
const sorted = [...items].sort((a, b) => b.price - a.price);
Common violations:
- Mutating props, state, or values returned from hooks.
- Reading or writing to
ref.currentduring render. - Calling hooks conditionally or in loops.
- Side effects directly in the component body, like writing to module variables or calling APIs.
When encountering such code, the compiler usually skips that component rather than breaking it. This is safe behavior, but you lose that optimization.
7. Gradual Migration in Large Projects
7.1. Opt-in Mode
With large codebases, you can compile only components you mark:
// vite.config.js (excerpt)
plugins: [["babel-plugin-react-compiler", { compilationMode: "annotation" }]],
export function InvoiceTable({ invoices }: { invoices: Invoice[] }) {
"use memo";
// ... this component gets compiled
}
The "use memo" directive at the start of the function body tells the compiler to process this component. Other components remain unchanged.
7.2. Exclude a Component Temporarily
If enabling it globally breaks a component, you can temporarily disable the compiler for just that one:
export function LegacyChart(props: ChartProps) {
"use no memo";
// ... old code using libraries that mutate data
}
Note:
"use no memo"is a temporary escape hatch. Write down why and create a task to fix it later, don’t let it become the default.
7.3. What About Existing useMemo?
The compiler works with code that already has useMemo/useCallback, so you don’t need to remove them immediately. Bulk removal could change subtle behaviors, especially when values are used as useEffect dependencies. Remove gradually with tests. For new components, just write pure code and let the compiler handle it.
8. Writing Tests for Memoization Behavior
You can verify that children don’t re-render when unrelated state changes. The example below uses Vitest and React Testing Library, running with the same Vite setup that enabled the 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"]; // not wrapped in useMemo
return (
<>
<button onClick={() => setClicks((c) => c + 1)}>Clicks: {clicks}</button>
<OrderList orders={orders} />
</>
);
}
describe("OrdersPage with React Compiler", () => {
it("does not re-render OrderList when only the counter changes", 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);
});
});
Explanation:
ordersis an array literal, not wrapped inuseMemo. Without the compiler, a new array is created on each render andOrderListre-renders.- With the compiler, both the
ordersarray and the<OrderList />element are cached because they don’t depend onclicks. renderSpyis called only once, on the first render.
Expected result:
✓ OrdersPage with React Compiler > does not re-render OrderList when only the counter changes
Test Files 1 passed (1)
If you disable the compiler, the final assertion would fail with a value of 3. The toHaveTextContent matcher requires @testing-library/jest-dom in your setup file.
Note: Calling a spy in the component body is a side effect. This is only acceptable in tests to measure render counts. Don’t do this in production code.
9. When NOT to Use (or Use Cautiously)
- Codebase violates Rules of React heavily: the compiler will skip most components, or worse, expose bugs that were previously hidden. Fix according to linting first.
- Libraries rely on mutation: some state or form libraries modify objects in place and expect components to read the new value on each render. Components using them may need
"use no memo". - Unusual build pipeline: if you don’t use Babel or your toolchain doesn’t support plugins, integration will be costly. Check bundler support first.
- Small apps with low interactivity: the benefit might not justify increased build time and debugging complexity.
- Need stable references for effects: the compiler memoizes for optimization, not guarantees. If your logic correctness depends on a value keeping the same reference, like preventing an effect from running again, keep
useMemo/useCallbackexplicit to show intent.
Also, when debugging, code running in the browser differs from what you wrote. Source maps help, but you should get familiar with DevTools and the compiler’s playground tool to see the output when needed.
10. Conclusion
React Compiler doesn’t eliminate useMemo and useCallback, but transforms them from everyday tools into escapes for edge cases. The prerequisite is that code must follow the Rules of React. So the first step is always to enable linting and fix violations.
Recommended roadmap: enable linting, fix warnings, enable the compiler in annotation mode for a few components, write tests measuring render counts, then gradually expand. This way, you get better performance without manual dependency arrays.