React Compiler vs useMemo and useCallback: What Actually Changes
Does the React Compiler replace useMemo and useCallback? What it memoizes automatically, the four cases where you still need them, and how to migrate safely.
The short answer: the React Compiler replaces most of your useMemo and useCallback calls, but not all of them. It handles memoization for rendering performance automatically. It does not handle the cases where memoization is load-bearing for correctness — referential identity that something outside React depends on.
That distinction is the whole thing. Once you can tell the two apart, the migration is straightforward and you can delete a lot of code.
This guide covers what the compiler actually does, the four cases where manual memoization is still required, and how to migrate an existing codebase without introducing subtle bugs.
What the Compiler Actually Does
The React Compiler is a build-time tool. It analyses your components, works out which values depend on which inputs, and inserts memoization automatically — roughly the same memoization an extremely diligent developer would write by hand, applied consistently everywhere.
Given this component:
function ProductList({ products, filter, onSelect }) {
const visible = products.filter((p) => p.category === filter);
const total = visible.reduce((sum, p) => sum + p.price, 0);
return (
<div>
<Summary total={total} />
{visible.map((p) => (
<ProductCard key={p.id} product={p} onSelect={() => onSelect(p.id)} />
))}
</div>
);
}
Pre-compiler, every render recomputed visible and total, and created a fresh arrow function for every card — so React.memo on ProductCard bought you nothing. The hand-optimized version needed useMemo twice, useCallback once, and a restructure to avoid the inline closure.
The compiler produces the equivalent optimization from the code above, unchanged. You write the readable version; the build output gets the memoized one.
What It Gets Right That Humans Often Don't
Hand-written memoization tends to fail in three predictable ways, all of which the compiler avoids:
- Wrong dependency arrays. The compiler derives dependencies from actual data flow instead of a hand-maintained list.
- Memoizing the cheap thing. Developers often wrap trivial arithmetic while the genuinely expensive derived value goes unmemoized.
- Inconsistency. Optimization tends to land on components someone profiled once, not on the components that need it now.
Consistency is the underrated benefit. The compiler applies the same rules to every component, including the ones written at 6pm on a Friday.
What the Compiler Does Not Do
This is where the migrations go wrong. The compiler optimizes rendering. Some memoization exists for other reasons, and stripping it breaks things.
1. Referential Identity Consumed Outside React
If a value's stable identity is depended on by something React does not control, you still need to guarantee it yourself. The most common case is a useEffect dependency where re-running has a real side effect:
// The compiler will not guarantee `options` identity across renders in a way
// this effect can rely on — and re-running it opens a new socket.
const options = useMemo(
() => ({ roomId, reconnect: true }),
[roomId]
);
useEffect(() => {
const socket = connect(options);
return () => socket.close();
}, [options]);
The better fix is usually to remove the object from the dependency array entirely — depend on roomId directly. But where you cannot restructure, the manual memo stays.
2. Genuinely Expensive Computation
The compiler memoizes to avoid unnecessary re-renders. It does not know that one particular function takes 400ms:
// Keep this. The cost here is CPU time, not reconciliation.
const layout = useMemo(
() => computeForceDirectedGraph(nodes, edges),
[nodes, edges]
);
Rule of thumb: if you would notice the computation in a profiler as a long task, memoize it explicitly and leave a comment saying why. That comment is what stops someone deleting it during the next cleanup.
3. Values Crossing Into Non-React Code
Third-party libraries, imperative APIs, and anything holding its own reference:
const chartConfig = useMemo(() => ({ type: "line", data }), [data]);
useEffect(() => {
chartInstance.current = new ExternalChart(el.current, chartConfig);
return () => chartInstance.current.destroy();
}, [chartConfig]);
The chart library caches the config object by reference. React's compiler has no visibility into that contract.
4. Custom Hooks Returning Objects Used as Dependencies
export function useFilters() {
const [state, setState] = useState(initial);
// Consumers put this in dependency arrays, so identity is part of the API.
return useMemo(() => ({ state, setState }), [state]);
}
When a hook's return value becomes someone else's dependency, its identity is a contract. Keep it explicit.
A Decision Table
| Situation | Keep manual memo? |
|---|---|
| Derived value used only in JSX | No — compiler handles it |
| Callback passed to a child component | No — compiler handles it |
| Filtering or mapping a list for render | No — compiler handles it |
Value in a useEffect dependency array with real side effects | Yes |
| Computation measurable in a profiler (>16ms) | Yes |
| Object handed to a non-React library | Yes |
| Custom hook return value used as a dependency | Yes |
React.memo on a component receiving props from uncompiled code | Usually yes |
The pattern: if the memoization exists so React renders less, delete it. If it exists so something stays referentially stable, keep it.
Migrating an Existing Codebase
Do not open a pull request that deletes every useMemo in the repository. The compiler's guarantees only hold for code that follows the Rules of React, and a large codebase almost certainly has violations that the compiler will silently skip.
Step 1: Enable the ESLint Plugin First
npm install --save-dev eslint-plugin-react-compiler
// eslint.config.mjs
import reactCompiler from "eslint-plugin-react-compiler";
export default [
{
plugins: { "react-compiler": reactCompiler },
rules: { "react-compiler/react-compiler": "error" },
},
];
Run it before enabling the compiler. Every error it reports is a component the compiler would bail out on — mutation during render, conditional hooks, mutated props. Fix those first, because a component the compiler skips keeps its original performance profile, and you will not be told at runtime.
Step 2: Turn the Compiler On, Change Nothing Else
// next.config.ts
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
Existing useMemo and useCallback calls are safe to leave in place. The compiler works around them. Ship this on its own and confirm nothing regresses.
Step 3: Verify the Compiler Is Actually Applying
Install React DevTools and look for the "Memo ✨" badge on compiled components. Components without it were skipped — check them against the lint output.
Compare bundle size and interaction timings before and after. In the projects I have migrated, the visible wins were in list-heavy and form-heavy screens; simple presentational trees changed very little, because there was little to gain there in the first place.
Step 4: Remove Manual Memoization Incrementally
Only now, and only in files you are already touching. For each useMemo or useCallback, ask the decision-table question. If it is render-only, delete it. If you are unsure, leave it — a redundant memo costs almost nothing, while removing a load-bearing one can cause an infinite effect loop.
Common Migration Bugs
Infinite useEffect loops. Someone removes a useMemo whose value feeds a dependency array. The effect now runs every render. Symptom: runaway network requests. Fix: restore the memo, or depend on primitives instead of the object.
Silent bail-outs. A component mutates a prop during render, the compiler skips it, and the team assumes it was optimized. Symptom: a screen that never got faster. Fix: read the lint output rather than assuming coverage.
Stale closures in uncompiled code. Mixing compiled components with a hand-written HOC or an older library that captures callbacks. Symptom: stale state in the callback. Fix: keep useCallback at that boundary.
Over-cleanup in one pass. A 300-file PR removing all memoization, impossible to review and impossible to bisect. Fix: incremental removal.
What This Means for New Code
Write the readable version first. Compute derived values inline, define handlers where they are used, and stop reaching for useCallback reflexively. Reach for manual memoization only when you can name the reason — an effect dependency, a measured expensive computation, or an external library contract — and write that reason in a comment.
That is a real change in day-to-day React. Reviews stop arguing about dependency arrays and start discussing what the code does. The compiler handles the mechanical part, which it does more consistently than any of us managed by hand.
What it does not do is fix architecture. A component that re-renders because it consumes a context holding six unrelated values will keep re-rendering, memoized or not. That is a structure problem — see React App Architecture: Best Practices for Scale for how to avoid it.
Related Reading
- React Compiler Adoption in 2026
- Should You Enable the React Compiler in Production?
- React App Architecture: Best Practices for Scale
- React Performance: Code Splitting and Lazy Loading
- React Concurrent Features: Transitions and Suspense
Memoization is one lever among several. If your renders are slow for reasons the compiler cannot reach, React and Next.js performance optimization starts with profiling rather than guessing — get in touch.