I Pointed the React Compiler at Our Codebase. Here's What Happened.
I Pointed the React Compiler at Our Codebase. Here's What Happened.#
TL;DR
- The React Compiler automatically inserts memoization that replaces
useMemo,useCallback, andReact.memo-- you write plain code, it handles the rest- I enabled it across three production apps and deleted 34
useCallbackcalls, 21useMemocalls, and 8React.memowrappers. Tests still passed. Performance was equal or better- It does not handle everything: mutations of props, non-standard library patterns, and genuinely expensive computations you want to control still need manual intervention
- My prediction: within 18 months, writing
useMemomanually will feel as outdated as writingshouldComponentUpdate
React Forget. That's what they called it at React Conf 2021, with slides showing automatic memoization and promises of "write plain JavaScript, the compiler handles optimization." The reaction at the time: sure, someday.
Someday arrived. The React Compiler went stable in 2025, and it's been running in production for six weeks. Here's the honest assessment — what it does, what it doesn't, and what happened when pointed at real code.
What the Compiler Actually Does#
It's a Babel/SWC plugin that transforms your component and hook source code at build time. It analyzes which values change between renders and inserts memoization at exactly the right granularity. You write the obvious code. It makes the obvious code fast.
Here's a concrete example from MoneyTrack:
// before the compiler: this recalculates every render
function BudgetSummary({ transactions, budgetLimit }: BudgetSummaryProps) {
const totalSpent = transactions
.filter((tx) => tx.type === "expense")
.reduce((sum, tx) => sum + tx.amount, 0);
const percentUsed = (totalSpent / budgetLimit) * 100;
const isOverBudget = totalSpent > budgetLimit;
return (
<div className={isOverBudget ? "bg-red-50" : "bg-green-50"}>
<p>Spent: {formatCurrency(totalSpent)}</p>
<ProgressBar value={percentUsed} max={100} />
</div>
);
}
Before the compiler, the responsible thing was to wrap those calculations in useMemo:
// the "correct" React 18 version: memoize everything
function BudgetSummary({ transactions, budgetLimit }: BudgetSummaryProps) {
const totalSpent = useMemo(
() =>
transactions
.filter((tx) => tx.type === "expense")
.reduce((sum, tx) => sum + tx.amount, 0),
[transactions]
);
const percentUsed = useMemo(
() => (totalSpent / budgetLimit) * 100,
[totalSpent, budgetLimit]
);
const isOverBudget = totalSpent > budgetLimit;
return (
<div className={isOverBudget ? "bg-red-50" : "bg-green-50"}>
<p>Spent: {formatCurrency(totalSpent)}</p>
<ProgressBar value={percentUsed} max={100} />
</div>
);
}
With the compiler enabled, the first version -- plain code, no useMemo -- gets the same performance as the second. The compiler statically determines that totalSpent depends on transactions, that percentUsed depends on totalSpent and budgetLimit, and inserts the equivalent memoization automatically. You write the simple code. It does the boring work.
The dependency analysis the compiler does is essentially a dataflow graph computation — which values flow into which computations, and which computations need to be re-evaluated when inputs change. The kind of problem that was enjoyable to reason about in theory, and far more enjoyable to have automated in practice.
Setting It Up#
For Next.js (which is what I use), it's one line:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
reactCompiler: true,
},
};
export default nextConfig;
For vanilla React with Babel:
{
"plugins": [
["babel-plugin-react-compiler", {}]
]
}
After enabling it, the entire test suite passed. All tests green. That's either a testament to the compiler or to the codebase following the Rules of React reasonably well. Probably both.
The Experiment: Three Real Codebases#
The compiler was enabled across three production apps, then each was audited systematically — removing manual memoization and verifying the results.
The app with the most complex UI (creator profiles, media kit layouts, portfolio grids with drag-and-drop) had 12 React.memo wrappers removed from its portfolio card components. Drag-and-drop remained smooth. The grid rendered quickly with 50+ items. No visible regression.
The finance app had the most useCallback calls — every sort handler, every filter toggle, every chart interaction callback was wrapped. 34 removed. The transaction table with 500+ rows still scrolls smoothly. The budget chart animates without jank. The Recharts integration worked fine (chart libraries sometimes have non-standard equality checking, but it turned out okay).
The education app had the fewest manual optimizations to begin with. 8 useMemo calls and 2 React.memo wrappers removed. No change in behavior.
Final tally across all three: 34 useCallback removed, 21 useMemo removed, 8 React.memo removed. Every test still green. Performance profiler showed equivalent or slightly better render times across the board.
What the Compiler Cannot Handle#
This is the important part. The compiler is not magic. It has a clear set of limitations, and understanding them will save you from confusing bugs.
Mutations of props or state. The compiler assumes your code follows the Rules of React -- pure renders, no side effects. If you mutate a prop (even accidentally), the compiler either skips that component or produces wrong output.
// the compiler CANNOT optimize this -- it mutates the prop
function SortableList({ items }: { items: Item[] }) {
items.sort((a, b) => a.name.localeCompare(b.name)); // mutation!
return <ul>{items.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;
}
// this is fine -- new array, no mutation
function SortableList({ items }: { items: Item[] }) {
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
return <ul>{sorted.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;
}
I found two instances of prop mutation in BeautyGlam during this exercise. They were bugs -- the components happened to work because nothing else read the mutated array before the next render, but they were still wrong. The compiler surfaced them. I fixed them. This was arguably the most valuable outcome of the whole experiment.
Components that escape React's model. If a component calls document.getElementById or other DOM APIs directly during render (not in effects), the compiler can't reason about timing correctly. I had one component in EduPlay that read scroll position during render to decide layout. I moved it to a useEffect + ref pattern and the compiler was happy.
Third-party objects with non-standard equality. Some libraries give you objects that look the same between renders but are different references internally. The compiler's memoization is reference-equality based. If your library recreates objects every render, the memoization won't help (but it also won't hurt -- you're back to the same behavior as before the compiler).
Expensive computations you want to control explicitly. If you have a computation that takes 50ms and you want to decide exactly when it runs -- maybe behind a debounce, maybe on a web worker -- useMemo is still the right tool. The compiler will memoize the computation automatically, but you can't control its granularity.
Opting Out#
For the components where the compiler genuinely doesn't work, there's an escape hatch:
function ComplexLegacyChart({ data }: { data: ChartData }) {
"use no memo"; // compiler directive -- skip this component
// ... code that does non-standard things with a charting library
}
It's a string literal directive, like "use client". I used it in exactly two places across all three apps -- a canvas-based animation in EduPlay and a third-party chart wrapper in MoneyTrack that was doing weird things with refs. If you need more than a handful of these, your code probably has Rules of React violations that should be fixed rather than bypassed.
Run the ESLint Plugin First#
This is my strongest recommendation: before you enable the compiler, install eslint-plugin-react-compiler and fix every violation it finds.
npm install --save-dev eslint-plugin-react-compiler
{
"plugins": ["react-compiler"],
"rules": {
"react-compiler/react-compiler": "error"
}
}
The plugin finds Rules of React violations that the compiler will struggle with. In my experience, most violations are either legacy patterns that should've been fixed years ago, or genuine bugs -- places where state was being mutated accidentally. Finding these before the compiler runs on production code is the right order of operations. I found three bugs this way that had been hiding in the codebase for months.
My Take#
The React Compiler is the feature the community has wanted since the hooks RFC in 2018. Every conference talk about "when should I useMemo?" and every code review comment about "you need useCallback here" -- all of that cognitive overhead is gone for idiomatic code.
But I want to push back on one framing I keep seeing: "you never need to think about performance again." That's not true. You still need to understand React's rendering model. You still need to know when to push expensive work to the server. You still need to profile when performance is a problem. The compiler optimizes what it can reason about statically. Application-level performance decisions -- what to fetch, when to fetch, how to structure your component tree -- are still yours.
The developer who understands why the compiler inserts memoization will always be in a better position than the one who treats it as magic. The understanding matters, even when the tooling automates the implementation.
My prediction: within 18 months, manually writing useMemo and useCallback will feel the same way manually writing shouldComponentUpdate feels today -- something we used to do, something the tooling handles now, something you only reach for in genuinely unusual circumstances. I'll revisit this in 6 months and see how it aged.