Open any React codebase built before late 2025 and you'll find the same defensive scaffolding in nearly every component: a memo() wrapper here, a useCallback there, a useMemo around a sort you were never quite sure was expensive enough to justify it. Most of that code was never a response to a measured problem — it was insurance against a re-render you were guessing might happen.
As of React Compiler 1.0, stable since October 2025, that guessing game is mostly over. The compiler does the same analysis you were doing by hand, applies it more precisely than hooks alone can, and in a few cases does things manual memoization structurally cannot do at all. This is episode two of React Deep Dive, and it's about what "automatic memoization" actually means, what it removes from your code, and what it deliberately leaves for you to keep deciding.
This article is written against React 19.2 (verified 19.2.8, the current npm release as of this writing) and React Compiler 1.0 (the stable babel-plugin-react-compiler@1.0.0 release, shipped October 7, 2025 at React Conf — verified against React's own release notes). If you want the re-render vocabulary this article leans on, episode one covered re-render vs. remount — useful background, not required reading.
What you'll learn
By the end of this article you'll be able to:
- Explain why React re-renders a whole subtree by default, and what memoization actually buys you when you add it
- Read code with
React.memo,useMemo, anduseCallbackand know exactly what problem each one was solving - Describe what React Compiler automates, including two cases manual hooks can't solve at all
- Know when you still need
useMemo/useCallbackeven with the compiler installed - Add the compiler to a project and read its lint diagnostics when it can't safely optimize something
Who this is for
You've written function components and used useState, useEffect, and at least one of useMemo/useCallback/React.memo before, even if you couldn't fully explain why. No compiler internals or build-tooling experience required.
Table of contents
- The problem: manual memoization doesn't scale
- The mental model: what memoization actually buys you
- Stage 1: the re-render, without memoization
- Stage 2: the manual fix, and its subtle crack
- Stage 3: the same code, compiled
- Stage 4: turning it on
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
The problem: manual memoization doesn't scale
Here's a dashboard with a search box and a team roster underneath it. Nothing exotic:
function Dashboard({ members }) {
const [query, setQuery] = useState("");
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<TeamRoster members={members} onInvite={(id) => sendInvite(id)} />
</div>
);
}
function TeamRoster({ members, onInvite }) {
const sorted = sortByActivity(members); // recomputed on every call
return (
<ul>
{sorted.map((m) => (
<MemberCard key={m.id} member={m} onInvite={() => onInvite(m.id)} />
))}
</ul>
);
}
members never changes while you type. But every keystroke updates query, which re-renders Dashboard, which re-renders TeamRoster — by default, with no memoization applied anywhere, React re-renders a component and everything below it whenever the component's own state or a parent's state changes. TeamRoster re-runs sortByActivity from scratch, builds a brand-new array, and hands every MemberCard a brand-new onInvite closure. Every card re-renders, on every keystroke, for a value that didn't change.
The textbook fix is to memoize the boundary:
const TeamRoster = memo(function TeamRoster({ members, onInvite }) {
const sorted = useMemo(() => sortByActivity(members), [members]);
const handleInvite = useCallback((id) => onInvite(id), [onInvite]);
return (
<ul>
{sorted.map((m) => (
<MemberCard key={m.id} member={m} onInvite={() => handleInvite(m.id)} />
))}
</ul>
);
});
This helps — sortByActivity only re-runs when members actually changes. But look closely at the line that renders each card: onInvite={() => handleInvite(m.id)}. That inline arrow function is created fresh on every render, useCallback wrapper or not, so MemberCard still gets a new onInvite prop every time and still re-renders. useCallback cannot fix this without restructuring the code — hooks can only stabilize values, and the value here is the arrow function around handleInvite, not handleInvite itself.
This is the actual shape of the problem: correct manual memoization requires you to trace every value that flows into every child, on every edit, forever. Miss one spot — and the spot above is easy to miss — and the memoization you added silently does nothing.
The mental model: what memoization actually buys you
The mental model: React re-renders a component whenever its own state changes and whenever its parent re-renders — regardless of whether the props it receives actually changed. Memoization doesn't stop a component from re-rendering because of its own state; it gives React a way to tell that a child's inputs didn't change, so React's reconciler can skip that child's subtree entirely rather than re-run it and diff the result.
Concretely: if a component returns the exact same element reference on two consecutive renders (not just equal-looking JSX, the same object in memory), React bails out of that subtree without touching it. React.memo gets you this by comparing props before re-rendering the child; useMemo/useCallback get you this by keeping the values passed into JSX stable, so the JSX built from them stays stable too.
React Compiler's entire job is producing that same stability automatically, everywhere it's provably safe to do so — by reading your component's code and figuring out, per value, whether it could have changed since the last render. It doesn't change when your component's own state causes it to re-render. It changes whether that re-render cascades into components that had nothing to do with the change.
Stage 1: the re-render, without memoization
Run the Dashboard/TeamRoster code above and every MemberCard logs a render on every keystroke — you can watch this happen for real in the playground below, which runs the actual React 19.2.8 runtime, not a simulation. This is React doing exactly what it's documented to do: no memoization means no bailout, so the whole subtree re-runs.
Key concept: an unmemoized re-render isn't a bug. It's React's default, and it's usually fine — React is fast enough that most re-renders never cost anything a user would notice. The problem only shows up when a subtree is expensive enough, or large enough, that redoing it on every keystroke becomes visible.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
Stage 2: the manual fix, and its subtle crack
The memo/useMemo/useCallback version above fixes the expensive sort but not the inline arrow, for a structural reason worth sitting with: hooks can only be called unconditionally, at the top level of a component, so you can't useCallback a function that's constructed inside a .map() callback per item — you'd be calling a hook in a loop, which breaks the Rules of Hooks. The only fix within hooks alone is to restructure the code, usually by pushing the click handler down into MemberCard and passing just the id.
Key concept: manual memoization isn't just tedious, it has real structural gaps — situations no combination of useMemo/useCallback/React.memo can close without changing how the code is written. That's the opening React Compiler was built to close.
Stage 3: the same code, compiled
With React Compiler enabled, you write the first version of TeamRoster — no memo, no useMemo, no useCallback, the inline arrow left exactly where it reads most naturally:
function TeamRoster({ members, onInvite }) {
const sorted = sortByActivity(members);
return (
<ul>
{sorted.map((m) => (
<MemberCard key={m.id} member={m} onInvite={() => onInvite(m.id)} />
))}
</ul>
);
}
The compiler analyzes the function body at build time and rewrites it — roughly, generating memoized slots for sorted and for each card's element, comparing them against the previous render's values, and reusing the old result when nothing relevant changed. According to React's own compiler documentation, this handles the inline-arrow case correctly with or without the arrow function, because the compiler is reasoning about the whole function's data flow, not applying a hook to a single named value.
This part is a model, not a live demo: React Compiler is a build-time Babel transform, so it cannot run inside a static HTML page without a bundler. The playground above shows the real, measurable effect — fewer re-renders — that flipping "apply manual memoization" produces with the actual React runtime; the compiled output is the same effect, generated for you, from the plain code shown here.
Two things the compiler can do that manual hooks structurally can't, both documented in its own release notes:
-
Memoize after an early return.
useMemoanduseCallbackcan't appear after a conditionalreturn— that's the Rules of Hooks. The compiler isn't a hook, so it can memoize values computed after one. - Handle the inline-arrow case above without you restructuring anything.
Stage 4: turning it on
Installing it is a dev dependency plus a build-tool integration:
npm install --save-dev --save-exact babel-plugin-react-compiler@latest
npm install --save-dev eslint-plugin-react-hooks@latest
eslint-plugin-react-hooks's recommended and recommended-latest presets now ship the compiler's lint rules directly — this replaced the separate eslint-plugin-react-compiler package when the compiler went stable, and the lint rules work even in projects that haven't added the compiler itself yet, because they're really flagging Rules-of-React violations.
New projects scaffolded with recent versions of Vite, Next.js (15.3.1+), or Expo (SDK 54+) can start with the compiler already wired in. Existing codebases adopt it incrementally: point the compiler at one directory or route first, watch the lint output, and expand from there.
Edge cases and gotchas
- It only compiles components and hooks — not arbitrary functions. A plain helper function called from inside a component gets memoized as a call, but if that same expensive helper is called from three different components, each one still pays the cost independently; the compiler's memoization isn't shared across components.
-
It requires the Rules of React. The compiler assumes your components and hooks are pure — idempotent given the same props/state, no mutating props or state during render, no side effects in render. Code that breaks these rules in ways the compiler can statically detect gets flagged (surfaced through
eslint-plugin-react-hooks, with rules likeset-state-in-renderandset-state-in-effect); code that breaks them in ways JavaScript can't statically catch may compile without warning and behave subtly differently than before. -
Removing existing manual memoization isn't automatically safe. React's own guidance is to leave existing
useMemo/useCallbackcalls in place, or test carefully before deleting them — the compiler's memoization boundaries won't always land in exactly the same places yours did, and if some effect elsewhere depends on one of your values staying referentially stable across specific renders, changing that boundary can change how often that effect fires. -
React 17 and 18 are supported, not just 19 — with a
targetconfig option and thereact-compiler-runtimepackage as an added dependency. On React 19 neither is needed. - It's a build-time transform, full stop. There's no runtime flag or devtools toggle; if it isn't wired into your bundler's config, none of this applies to your app.
Best practices
-
New code: stop hand-memoizing by default. Write the plain version. Reach for
useMemo/useCallbackonly when you need explicit control over a value's identity — most commonly, when that value is a dependency of an effect and you need to guarantee it won't cause the effect to over-fire. - Turn on the compiler-powered lint rules even before you install the compiler. They're Rules-of-React checks, and they catch real bugs (state updates during render, unsafe ref reads) independent of whether you've adopted the compiler yet.
-
Adopt incrementally in an existing codebase. Compile one route or directory, watch for lint diagnostics and behavior regressions, then widen the scope. Pin the compiler to an exact version (
--save-exact) rather than a semver range if your test coverage is thin, since future versions may change memoization boundaries. -
Don't reach for the compiler to fix a slow function. If
sortByActivityabove were genuinely expensive, calling it from several components would still re-run it in each one — profile first, and consider your own caching if the same expensive call is duplicated across the tree. -
Use
"use no memo"as a scalpel, not a habit. It opts one function out of compilation, useful while debugging a compiler diagnostic or isolating code the compiler can't yet handle — not a default you sprinkle everywhere "to be safe."
FAQ
Does React Compiler replace useEffect?
No. The compiler is entirely about memoizing render-time values and JSX; effects are how you synchronize with something outside React, and the compiler has no opinion on what belongs in one or when it should run.
Do I still need React.memo, useMemo, or useCallback with the compiler installed?
Not by default — the compiler applies equivalent memoization automatically in most cases. They remain available as an explicit escape hatch, most notably when a value is used as an effect's dependency and you need to guarantee its identity stays stable on purpose.
Is it safe to delete all my existing useMemo/useCallback calls right now?
Not automatically. React's own release guidance is to leave existing memoization in place, or remove it only after careful testing, because the compiler's generated memoization can land on slightly different boundaries than yours did.
Does React Compiler work with React 18 or older?
Yes — it supports React 17 and up. Below React 19 you add a target in the compiler config and depend on react-compiler-runtime; on React 19 that extra dependency isn't needed.
What happens if my component breaks the Rules of React?
The compiler's validation passes encode the Rules of React and surface violations as diagnostics through eslint-plugin-react-hooks. Statically detectable violations get flagged rather than silently miscompiled; violations JavaScript can't detect at compile time are the reason React recommends good test coverage before relying on the compiler in production.
How do I stop the compiler from touching one specific component?
Add the "use no memo" directive as the first line of that function's body.
Cheat sheet
| Task | Before (manual) | With React Compiler | Notes |
|---|---|---|---|
| Skip re-rendering a child when unrelated state changes | React.memo(Child) |
Automatic | Compiler keeps the child's JSX reference stable so React's reconciler bails out |
| Stabilize a callback passed to a memoized child | useCallback(fn, [deps]) |
Automatic | Handles inline arrows written straight in JSX, which manual hooks can't |
| Avoid recomputing an expensive render-time value | useMemo(() => calc(x), [x]) |
Automatic | Only for values computed inside a component or hook |
| Memoize a value defined after an early return | Not possible — breaks Rules of Hooks | Automatic | One of the compiler's documented advantages over hooks |
| Guarantee a value's identity for an effect dependency |
useMemo/useCallback
|
Still useMemo/useCallback
|
Documented escape hatch — keep using it here |
| Opt one function out of compilation | — |
"use no memo" directive |
For debugging or code incompatible with the compiler |
| Enable Rules-of-React lint checks |
eslint-plugin-react-compiler (superseded) |
eslint-plugin-react-hooks@latest, recommended preset |
Ships the compiler's lint rules directly since 1.0 |
| Run on React 17/18 | — | Add react-compiler-runtime + target config |
React 19 needs neither |
# Minimal install for a React 19 project
npm install --save-dev --save-exact babel-plugin-react-compiler@latest
npm install --save-dev eslint-plugin-react-hooks@latest
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
Key takeaways
- React re-renders a component's whole subtree by default whenever its state changes; memoization's actual job is giving children a stable reference so React's reconciler can bail out of re-rendering them.
- React Compiler 1.0, stable since October 2025, automates that memoization at build time — including cases hooks structurally cannot solve, like an inline arrow written in JSX or a value defined after an early return.
- It only compiles components and hooks that hold to the Rules of React, and it only memoizes work inside them — not arbitrary functions shared across components.
-
useMemo/useCallbackaren't obsolete. Keep them where a value's referential identity is a correctness requirement, like an effect dependency, and don't strip existing memoization from old code without testing first.
The pile of useMemo/useCallback/memo() wrappers from the opening paragraph isn't gone because you finally found time to delete it by hand — it's gone because, as of React 19.2 with the compiler enabled, you stop needing to write most of it in the first place. The insurance policy against re-renders you were never sure would happen is now something the build step carries for you.
Have you turned the compiler on in a real codebase yet — did the lint rules catch anything you didn't expect? Tell me in the comments.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)