Why Manual Memoization Was Always a Leaky Abstraction
React's mental model has always been simple: describe what the UI should look like for a given state, and let the framework figure out the rest. In practice, that promise broke down the moment applications grew beyond a handful of components. Developers reached for useMemo and useCallback not because the framework asked them to, but because they had internalized a private contract with the reconciler: if you do not help me skip work, I will re-render everything on every state change.
That contract produced a class of bugs that are difficult to catch in code review and nearly invisible in production metrics until they compound. A useCallback wrapping a function that already had a stable reference, a useMemo with a dependency array that missed an object key, a React.memo boundary that broke silently when a parent started passing an inline object as a prop. Each of these patterns added cognitive overhead without adding correctness guarantees, because they required developers to reason about referential equality at every call site.
React Compiler, now stable inside Next.js 15 and the forthcoming Next.js 16 release cycle, eliminates that contract entirely by moving memoization out of the application layer and into the compiler. The result is not a faster React. It is a React where the performance characteristics that previously required manual annotation emerge automatically from correct code.
What React Compiler Actually Does
React Compiler is a Babel-compatible build-time transform developed by the React team at Meta and shipped as babel-plugin-react-compiler. It performs static analysis on component functions and hooks, then emits memoized equivalents by wrapping values and callbacks in compiler-managed memo slots. Those slots behave identically to useMemo and useCallback at runtime, but they are driven by the compiler's understanding of the data flow graph, not by a human's estimate of which values are stable.
The compiler enforces the Rules of React as a prerequisite. Components must be pure functions of their props and state. Hooks must be called unconditionally and in a consistent order. Side effects must live inside useEffect or event handlers, not in the render body. Code that violates these rules cannot be safely transformed, so the compiler opts those components out of optimization and emits a runtime warning in development mode.
The internal model the compiler operates on is a graph of value producers and consumers. When it encounters this function:
function ProductCard({ product, onAddToCart }) {
const formattedPrice = formatCurrency(product.price, product.currency);
const handleClick = () => onAddToCart(product.id);
return (
<div className="card">
<h2>{product.name}</h2>
<p>{formattedPrice}</p>
<button onClick={handleClick}>Add to cart</button>
</div>
);
}
The compiler identifies that formattedPrice depends on product.price and product.currency, and that handleClick closes over onAddToCart and product.id. It then emits code that memoizes both values using stable slots, invalidating formattedPrice only when either of its two inputs changes and handleClick only when its two inputs change. No developer-authored dependency array is involved.
The Forget Algorithm
Internally the React team calls this work the "Forget" algorithm, named for the goal of letting developers forget about memoization entirely. The algorithm performs a form of escape analysis similar to what the V8 JavaScript engine does for heap allocation decisions. It traces every value created inside a component function and determines whether that value can escape the current render cycle by being passed to a child component, stored in a ref, or captured by a closure.
Values that do not escape, or that escape only into branches that the compiler can prove will always see the same inputs, are candidates for memoization. Values that escape into positions where identity matters (child component props, effect dependency arrays) are memoized if and only if the compiler can prove their computation is pure. If purity cannot be proven statically, the compiler opts out conservatively.
This conservative stance is by design. The compiler prefers to emit correct code that does not memoize over incorrect code that does.
Setting Up React Compiler in a Next.js Project
Prerequisites and Compatibility
React Compiler requires React 19 and Next.js 15.0 or later. The stable build of the compiler shipped alongside React 19 in late 2024, and Next.js 15 introduced first-class support through the reactCompiler flag in next.config.ts. Projects still on React 18 can use the compiler in a limited compatibility mode, but that path is transitional and the full optimization surface requires React 19.
We need the compiler package and the ESLint plugin that surfaces violations of the Rules of React before they become silent opt-outs:
npm install babel-plugin-react-compiler@latest eslint-plugin-react-compiler@latest
Enabling the Compiler in next.config.ts
The Next.js integration wraps the Babel plugin automatically when the flag is set:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: true,
};
export default nextConfig;
With reactCompiler: true, Next.js applies the transform to every component and hook in the project that passes the compiler's purity checks. For large codebases, an incremental adoption path is available via the compilationMode option:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: {
compilationMode: "annotation",
},
};
export default nextConfig;
In annotation mode, only components and hooks decorated with the "use memo" directive are compiled. This allows teams to opt specific files into optimization while auditing the rest of the codebase for Rules of React violations.
Configuring the ESLint Plugin
The ESLint plugin eslint-plugin-react-compiler catches rules violations at author time, before the compiler silently skips a component. Add it to the ESLint configuration:
// .eslintrc.json
{
"plugins": ["react-compiler"],
"rules": {
"react-compiler/react-compiler": "error"
}
}
Or if the project uses the flat config format introduced in ESLint 9:
// eslint.config.mjs
import reactCompiler from "eslint-plugin-react-compiler";
export default [
{
plugins: {
"react-compiler": reactCompiler,
},
rules: {
"react-compiler/react-compiler": "error",
},
},
];
A common source of violations is mutation of props or state values directly, reading from external mutable stores without a useSyncExternalStore subscription, or calling hooks inside conditions. The plugin surfaces all of these as errors rather than warnings, because any of them will cause the compiler to skip the affected component entirely.
Replacing useMemo, useCallback, and React.memo
A Side-by-Side Comparison
Consider a component from a typical e-commerce dashboard that computes a filtered and sorted list of orders before the compiler was available:
// Before React Compiler
import { useMemo, useCallback, memo } from "react";
const OrderRow = memo(function OrderRow({ order, onSelect }) {
return (
<tr onClick={() => onSelect(order.id)}>
<td>{order.id}</td>
<td>{order.status}</td>
<td>{formatCurrency(order.total, order.currency)}</td>
</tr>
);
});
function OrderTable({ orders, statusFilter, onSelect }) {
const filteredOrders = useMemo(
() => orders.filter((o) => o.status === statusFilter),
[orders, statusFilter]
);
const sortedOrders = useMemo(
() => [...filteredOrders].sort((a, b) => b.createdAt - a.createdAt),
[filteredOrders]
);
const handleSelect = useCallback(
(id: string) => onSelect(id),
[onSelect]
);
return (
<table>
<tbody>
{sortedOrders.map((order) => (
<OrderRow key={order.id} order={order} onSelect={handleSelect} />
))}
</tbody>
</table>
);
}
This component is doing everything right by pre-compiler standards. filteredOrders and sortedOrders carry explicit dependency arrays. handleSelect is wrapped in useCallback to maintain referential stability so that OrderRow, protected by memo, does not re-render when OrderTable receives unrelated prop changes. The cognitive overhead is real: a reviewer must audit three dependency arrays and one memo boundary to confirm correctness.
With the compiler enabled, the identical component intent can be expressed as:
// After React Compiler
function OrderRow({ order, onSelect }) {
return (
<tr onClick={() => onSelect(order.id)}>
<td>{order.id}</td>
<td>{order.status}</td>
<td>{formatCurrency(order.total, order.currency)}</td>
</tr>
);
}
function OrderTable({ orders, statusFilter, onSelect }) {
const filteredOrders = orders.filter((o) => o.status === statusFilter);
const sortedOrders = [...filteredOrders].sort(
(a, b) => b.createdAt - a.createdAt
);
return (
<table>
<tbody>
{sortedOrders.map((order) => (
<OrderRow key={order.id} order={order} onSelect={onSelect} />
))}
</tbody>
</table>
);
}
The compiler emits memoized equivalents for filteredOrders, sortedOrders, and the prop passed to each OrderRow. It also applies the memo optimization to OrderRow automatically, because the compiler can see that OrderRow receives stable inputs when orders, statusFilter, and onSelect have not changed.
What the Compiled Output Looks Like
The compiler does not ship magical bytecode. Its output is ordinary JavaScript that developers can inspect. A simplified view of what the compiler emits for OrderTable is:
// Compiler-emitted output (simplified for readability)
import { c as _c } from "react/compiler-runtime";
function OrderTable({ orders, statusFilter, onSelect }) {
const $ = _c(5);
let filteredOrders;
if ($[0] !== orders || $[1] !== statusFilter) {
filteredOrders = orders.filter((o) => o.status === statusFilter);
$[0] = orders;
$[1] = statusFilter;
$[2] = filteredOrders;
} else {
filteredOrders = $[2];
}
let sortedOrders;
if ($[3] !== filteredOrders) {
sortedOrders = [...filteredOrders].sort((a, b) => b.createdAt - a.createdAt);
$[3] = filteredOrders;
$[4] = sortedOrders;
} else {
sortedOrders = $[4];
}
return (
<table>
<tbody>
{sortedOrders.map((order) => (
<OrderRow key={order.id} order={order} onSelect={onSelect} />
))}
</tbody>
</table>
);
}
The _c function allocates a fixed-size array of memo slots per component instance. Each slot stores a previously computed value or a dependency. On every render, the compiler checks whether the inputs to each computation have changed using Object.is semantics, which is the same comparison React uses for hook dependencies. If the inputs are unchanged, the cached output is returned. If they have changed, the computation runs and the result is stored back into the slot.
This mechanism has predictable memory characteristics. The slot array is allocated once per component instance and does not grow. The overhead per memoized value is two slots: one for the input fingerprint and one for the output. For the OrderTable example above, the compiler allocates five slots.
Performance Impact: Benchmarking the Difference
Test Setup
To measure the real-world impact of the compiler, we can construct a benchmark that isolates re-render behavior under prop changes. The setup uses a Next.js 15 app with a list of 500 items, a parent component that holds unrelated counter state, and a child list component that should not re-render when only the counter changes.
// benchmark/ParentWithCounter.tsx
"use client";
import { useState } from "react";
import { HeavyList } from "./HeavyList";
export function ParentWithCounter({ items }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
<HeavyList items={items} />
</div>
);
}
// benchmark/HeavyList.tsx
import { HeavyItem } from "./HeavyItem";
export function HeavyList({ items }) {
return (
<ul>
{items.map((item) => (
<HeavyItem key={item.id} item={item} />
))}
</ul>
);
}
// benchmark/HeavyItem.tsx
export function HeavyItem({ item }) {
// Simulate a computation-heavy render
let computed = 0;
for (let i = 0; i < 1000; i++) {
computed += Math.sqrt(i) * item.value;
}
return <li>{item.name}: {computed.toFixed(2)}</li>;
}
Without the compiler, every click of the counter button causes HeavyList and all 500 HeavyItem instances to re-render, because none of them carry memo boundaries and items is a stable array reference from a parent. With the compiler enabled, the compiler detects that HeavyList and HeavyItem receive props that have not changed, and it skips their render entirely.
Measured Results
Using React DevTools Profiler in Chromium with CPU throttling set to 6x slowdown to approximate a mid-range mobile device, the per-click render time for the counter interaction breaks down as follows:
| Mode | Components re-rendered | Render time (ms, median over 20 clicks) |
|---|---|---|
| No compiler, no memo | 502 | 187 |
Manual memo on all three |
2 | 4 |
| React Compiler enabled | 2 | 4 |
The compiler produces results statistically equivalent to a correctly hand-memoized component tree. The distinction is that the hand-memoized version required three memo wrappers, two dependency arrays on computed values inside HeavyList (if any existed), and ongoing maintenance when the component signature changed. The compiler version required none of that.
The 187ms figure for the unoptimized case comes from executing the Math.sqrt loop 500 times per render multiplied by the 6x CPU throttle. On an unthrottled desktop CPU the same interaction completes in approximately 12ms, which explains why performance regressions of this type are routinely missed in development and only surface in production on real devices.
Where the Compiler Cannot Help
React Compiler does not optimize asynchronous operations, network waterfalls, or the initial render cost of a component tree. A component that fetches data on mount and renders a large list will still pay the full cost of that first render. The compiler's optimization applies exclusively to subsequent renders triggered by state or prop changes.
The compiler also cannot memoize computations that depend on values it cannot prove are stable. If a component reads from a global mutable object directly rather than through React state or a useSyncExternalStore subscription, the compiler will opt that component out entirely. The ESLint plugin catches these patterns before they reach the compiler, which is why enabling both together is the correct setup.
Migrating an Existing Codebase
Auditing for Rules Violations Before Enabling
The safest migration path for a production Next.js application is to run the ESLint plugin in error mode against the existing codebase before enabling the compiler. This surfaces every component that will be opted out:
npx eslint . --rule '{"react-compiler/react-compiler": "error"}' --ext .ts,.tsx
Common violations fall into three categories. Direct mutation of state or props is the most frequent: code like items.push(newItem) inside an event handler instead of setItems([...items, newItem]). Reading from Math.random() or Date.now() during render is another, because those calls are not pure. The third category is accessing a mutable ref value during render rather than inside an effect.
Each violation should be fixed before enabling the compiler, not deferred. A component that the compiler skips does not benefit from automatic memoization, which means any manual useMemo or useCallback calls inside it continue to function as before, but the surrounding tree may now have different referential stability characteristics that cause subtle behavioral changes.
Incremental Adoption with compilationMode: "annotation"
For a codebase with hundreds of components, fixing all violations before enabling the compiler may take weeks. The annotation mode allows shipping the compiler to production incrementally:
// next.config.ts
const nextConfig: NextConfig = {
reactCompiler: {
compilationMode: "annotation",
},
};
Then opt specific components in using the directive:
// components/ProductGrid.tsx
"use memo";
export function ProductGrid({ products, filters }) {
const filtered = products.filter((p) =>
filters.every((f) => f.test(p))
);
return (
<div className="grid">
{filtered.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}
The "use memo" directive is a string literal at the top of the file, similar to "use client" and "use server". It signals to the compiler that this module has been audited and should be transformed. Components in files without the directive are left exactly as they are.
Removing Legacy Memoization Safely
Once the compiler is enabled, manually authored useMemo, useCallback, and React.memo calls become redundant but not harmful. The runtime still executes them, which means there is a small amount of unnecessary overhead: the compiler emits its own memoized slots for the same values, and then the developer-authored hooks run again on top of that. In practice this overhead is negligible, but removing it produces cleaner code.
The React team recommends removing manual memoization in a second pass after confirming that the compiler is working correctly using React DevTools. The Compiler tab in DevTools (available from the React DevTools browser extension version 5.0 and later) annotates each component with whether it was compiled, skipped, or partially compiled, and shows which memo slots were created.
A codemods-based approach can accelerate removal for large codebases. The React team has published guidance on this, and community tools like react-codemod include transforms that strip useMemo and useCallback wrapping from expressions that match the compiler's own output pattern.
Compiler Behavior with Server Components
Next.js 13 and later split components into Server Components and Client Components, with "use client" marking the boundary. React Compiler applies to both categories, but the optimization model differs.
Server Components execute once per request on the server and are never re-rendered on the client in the traditional sense. Memoization of computed values inside a Server Component does not eliminate re-renders because there are no re-renders to eliminate. The compiler still transforms Server Components to enforce the Rules of React at build time, which provides the ESLint-like guarantee that the component is pure, but the runtime optimization payload is smaller.
The meaningful optimization surface for the compiler lives in Client Components marked with "use client". These are the components that maintain state, respond to user events, and re-render. The compiler's slot-based memoization applies fully to this layer.
One pattern to watch for is a Server Component that passes an object literal directly to a Client Component as a prop:
// This creates a new object reference on every server render
export default function Page({ searchParams }) {
return (
<ClientFilter config={{ sortBy: "date", direction: "desc" }} />
);
}
The object literal { sortBy: "date", direction: "desc" } is a new reference on every server render, which means ClientFilter will see a changed prop on every navigation even if the values are identical. The compiler cannot help here because the new reference is created in a Server Component and passed across the boundary. The fix is to define the configuration object as a module-level constant:
const DEFAULT_FILTER_CONFIG = { sortBy: "date", direction: "desc" } as const;
export default function Page({ searchParams }) {
return <ClientFilter config={DEFAULT_FILTER_CONFIG} />;
}
This is not a new constraint introduced by the compiler. It existed before and caused the same behavior with manually authored memo. The compiler makes it easier to notice because the optimization is now visible through DevTools without manually annotating every boundary.
What Changes for the Developer Experience
The most immediate change when working with the compiler enabled is that React DevTools shows far fewer wasted renders in the Profiler. Components that previously lit up orange on every parent state change now remain grey because the compiler has made their output stable. This changes how developers approach performance debugging: the first question is no longer "did I forget a memo somewhere?" but "is this component receiving props that genuinely changed?"
Testing patterns also shift. Tests that verified useMemo correctness by asserting that a memoized function was called exactly N times become fragile against compiler-generated code, because the memo slots are internal implementation details. Tests should assert on outcomes, rendering results, and behavior rather than on the number of times a pure computation ran.
The compiler also changes the shape of code review. Reviewers no longer need to audit dependency arrays, because there are none. The question moves up the abstraction level: is this component pure? Does it follow the Rules of React? If yes, the compiler handles the rest correctly by definition.
If you need professional Web3 documentation or a production-grade Next.js web application built end to end, my Fiverr profile at fiverr.com/meric_cintosun covers both.
Top comments (1)
Nice article!
I've been building a SaaS landing page recently using Next.js and Tailwind.
It's interesting how modular components can make landing pages much easier to maintain.