22일

2026년 4월 22일

The React Compiler at Eighteen Months: The Arc, the Debates, and What's Next

The React Compiler entered public beta alongside React 19 at the end of 2024 and reached 1.0 in October 2025. Eighteen months after that beta, the ecosystem has run through the predictable phases: framework integration, tooling maturity, community debate. A retrospective and what the React team has signaled comes next.

S
Sascha Becker
Author

약 15분

The React Compiler at Eighteen Months: The Arc, the Debates, and What's Next

React 19 shipped at the end of 2024, with the React Compiler arriving alongside it in public beta. The compiler was already running in production at Meta, the React team explicitly endorsed it for adoption, and 1.0 was finally tagged in October 2025. Eighteen months on from that beta, the ecosystem has run through the phases you would expect from any large platform-level change: announcement, framework integration, tooling maturity, and then the slower, messier work of debating what the thing actually means.

This post is a retrospective on that arc and a reading of what the React team has signaled for what comes next. It is not an explainer, though the explainer bits are there as reference. I have not done a large production migration myself. What follows draws on the public documentation, RFCs, React team talks, and the early-adopter write-ups linked at the end.

The Eighteen-Month Arc

Since the beta announcement in October 2024, the compiler's story has followed a predictable shape: release, integration, tooling, debate.

Release and early integration. In the months after React 19 shipped, the compiler's practical surface was mostly configuration. Next.js, Expo, TanStack Start, and the Vite-based frameworks each wired it into their build pipeline. For a new app, the story became "it's on by default, and you can turn it off if you need to." For existing apps, a migration path emerged around the ESLint plugin as a leading indicator of readiness.

The quiet middle. The middle of the arc was quieter than the initial hype suggested. Teams that adopted early did so with more skepticism than fanfare, and the wins they reported were the boring kind: fewer re-render bugs surfacing in code review, a drop in the "why is this slow" bug category, a codebase that stopped accumulating new memoization sprawl. The dramatic benchmark posts the community braced for never really arrived, because the compiler's biggest impact is avoiding bugs rather than posting headline numbers.

The ecosystem reckoning. By late 2025 the conversation shifted from "should we adopt" to "what do we do about the libraries that break it." That is where most teams are still stuck. The Rules of React, which were always true, became enforceable at build time, and a surprising amount of the ecosystem had been quietly bending them. Older state libraries, legacy forms, some drag-and-drop implementations, a handful of well-loved utility hooks. The compiler did not break them; it made it visible that they had been broken all along.

The current state, in a sentence: greenfield is solved, brownfield is a project.

What the Compiler Does

In brief, for reference. The compiler is a build-time transform. It reads your components, assumes they follow the Rules of React, and inserts memoization where it helps. There is no runtime cost from the compiler itself; the output is plain React. The granularity is better than what a human writes: where a hand-written useMemo memoizes a whole derived object, the compiler can memoize individual sub-expressions inside it, so changing one field does not invalidate the others.

Practically, code like this:

tsx
const DashboardRow = memo(({ entity, onSelect }: Props) => {
const formatted = useMemo(
() => ({
label: formatLabel(entity),
total: entity.items.reduce((sum, i) => sum + i.value, 0),
status: entity.state === "active" ? "green" : "red",
}),
[entity],
);
const handleClick = useCallback(() => {
onSelect(entity.id);
}, [entity.id, onSelect]);
return (
<Row onClick={handleClick}>
<Label color={formatted.status}>{formatted.label}</Label>
<Total>{formatted.total}</Total>
</Row>
);
});

Becomes code like this in a compiler-enabled codebase:

tsx
function DashboardRow({ entity, onSelect }: Props) {
const label = formatLabel(entity);
const total = entity.items.reduce((sum, i) => sum + i.value, 0);
const status = entity.state === "active" ? "green" : "red";
return (
<Row onClick={() => onSelect(entity.id)}>
<Label color={status}>{label}</Label>
<Total>{total}</Total>
</Row>
);
}

The memo wrapper, the useMemo, the useCallback: gone. The most visible payoff during interaction is a change in the scaling shape of re-renders. The classic failure case is a list where one bad dependency in a parent's useCallback forces every row to re-render on every keystroke; cost scales with list size until input lag shows up. The compiler removes that scaling. Rows whose props did not change stay untouched regardless of list size.

The costs, for completeness: build time goes up in the tens-of-percent range on public benchmarks (incremental builds are mostly unaffected), and bundle size ticks up a low single-digit percent from inlined memoization helpers. Both are project-specific; do not trust any exact number you read without measuring your own.

Where the Compiler Fails

Eighteen months of production use has sharpened the list of things the compiler will not touch. The four patterns below come up in every migration write-up I have read.

1. Mutating props or closures during render

tsx
function Row({ entity }: Props) {
entity.lastSeen = Date.now(); // mutation during render
return <span>{entity.name}</span>;
}

The compiler refuses to transform this. Fix the code.

2. Reading a ref during render

tsx
function Tooltip() {
const ref = useRef<HTMLDivElement>(null);
const width = ref.current?.offsetWidth; // reads ref in render body
return <div ref={ref}>{width}px</div>;
}

Move the read into an effect or useLayoutEffect. The compiler flags this but does not rewrite it.

3. Legacy class components

Class components are not compiled at all. If you still have React.Component subclasses, they run as they always did. Rarely a problem in new projects; worth knowing in older codebases.

4. Unsupported syntax inside otherwise-fine components

This is the failure mode that surprises people. The compiler bails on individual syntax patterns it cannot reason about, and the bail is silent unless you have the right lint rule enabled. The patterns that come up most:

tsx
// Reassigning a destructured prop
function Field({ value }: Props) {
value = value ?? defaultValue; // compiler skips this function
return <input defaultValue={value} />;
}
// Conditionals, optional chaining, or nullish coalescing inside try/catch
async function load(url: string) {
try {
const res = await fetch(url);
const data = ((await res.json()) as Data | undefined) ?? {};
if (data.ok) setResult(data); // also skipped
} catch (e) {
logError(e);
}
}

Both compile as plain React. Neither is a runtime bug. The trap is that the surrounding component quietly stops being memoized, and you find out by way of a re-render regression no profiler was looking for.

The fix is the react-hooks/unsupported-syntax rule, which ships in eslint-plugin-react-hooks v6+ (the package that absorbed eslint-plugin-react-compiler in late 2025). It is in recommended from v7 onward, but as a warning. CI configurations that fail only on errors will skim past it. Promote it to error if you want silent skips to actually break builds.

5. The "use no memo" escape hatch

Sometimes the compiler is wrong, or the cost of making a component compiler-safe is not worth paying yet. You can opt out one function at a time:

tsx
function ComplicatedLegacyThing() {
"use no memo";
// compiler skips this function, treats it as plain React
...
}

The Migration Path Most Teams Follow

Based on the React docs and the integration guides for Next.js, Expo, and TanStack Start, the commonly recommended sequence is short:

  1. Update React to a version that supports the compiler (React 19 or later).
  2. Get the ESLint plugin in place and tune severities. Since late 2025 the rules live in eslint-plugin-react-hooks v6+ (the standalone eslint-plugin-react-compiler was deprecated and merged in). If your project already uses a framework preset like eslint-config-next v16+, the v7 plugin is pulled in transitively and the recommended set is already active, so there is nothing new to install for most readers. The actionable step is to promote rules from warn to error, in particular react-hooks/unsupported-syntax, so silently-skipped components surface as lint failures rather than performance regressions. Fix what they find before touching compiler config.
  3. Enable the compiler in annotation-driven mode. Compile one or two leaf components first and observe.
  4. Switch to inference mode. Let the compiler decide which files to handle. Run tests, profile a real interaction.
  5. Remove the manual memoization. useMemo, useCallback, React.memo calls the compiler now handles can be deleted in bulk.
  6. Consider strict mode, once the codebase is clean, so violations throw instead of silently skip.

Teams that skip directly to step 5 tend to produce one enormous PR that sits open for weeks. Steps 1 and 2 land independently and improve code quality on their own.

What "Enabling the Rules" Actually Means in Practice

A few things only become visible once you turn the strict rule set on against an existing codebase, rather than reading the rule list cold.

A concrete starting point for the strict configuration, dropped into a flat eslint.config.mjs:

js
{
rules: {
"react-hooks/unsupported-syntax": "error",
"react-hooks/exhaustive-deps": "error",
"react-hooks/incompatible-library": "error",
"react-hooks/todo": "error",
"react-hooks/syntax": "error",
"react-hooks/capitalized-calls": "error",
"react-hooks/rule-suppression": "error",
"react-hooks/no-deriving-state-in-effects": "error",
"react-hooks/void-use-memo": "error",
"react-hooks/automatic-effect-dependencies": "error",
"react-hooks/memoized-effect-dependencies": "error",
"react-hooks/hooks": "error",
},
}

The first three promote rules recommended already ships at warn. The rest are the off-by-default compiler rules. No plugin to install if you are on eslint-config-next v16+ or any modern framework preset that pulls in eslint-plugin-react-hooks v7.

react-hooks/todo catches the most. It is not on by default and is not currently documented on react.dev, but it is the rule that surfaces the broadest set of compiler-internal lowering failures, the patterns the compiler simply has not taught itself to handle yet. Two examples that come up in real codebases:

tsx
// Mutated counter captured inside .map() lambdas
let globalIndex = 0;
return (
<>
{groupA.map((row, i) => render(row, i, globalIndex++))}
{groupB.map((row, i) => render(row, i, globalIndex++))}
</>
);
// Dynamic import() inside an effect
useEffect(() => {
void import("heavy-lib").then(({ default: lib }) => {
/* ... */
});
}, []);

Both snippets compile and run, and both silently take the surrounding component out of the compiler's hands. The fixes are mechanical: replace the mutated counter with precomputed offsets (groupB.map((row, i) => render(row, i, offsetB + i))), and hoist the dynamic import into a module-level cached promise so the Import expression no longer lives inside the component body. Worth turning the rule on for the strictest possible signal, with the caveat that the output is noisier than unsupported-syntax.

Silent skips cascade. Once a component bails, downstream rules sometimes do not surface their own findings on the same component, the analysis can stop at the first failure. Fixing the upstream skip can immediately reveal a second issue. Treat lint cleanup as iterative rather than one-shot. The first run gives you the count; subsequent runs give you the truth.

Most production code passes. The other compiler rules off by default (syntax, capitalized-calls, rule-suppression, no-deriving-state-in-effects, void-use-memo, the effect-deps rules) tend to be quiet on codebases that follow ordinary React idioms. Enable them anyway, the false positive rate is low and the surface area where they catch something is precisely the surface area you cannot afford to leave silent.

What's Still Contested

Three things the community has not settled eighteen months in.

Rules of React as an enforceable contract

The Rules of React existed before the compiler, but they were aspirational. The compiler turns them into a build-time boundary: violate them and your component silently opts out of memoization. A vocal minority argues this quietly imposes a stricter programming model than React itself ever promised. The counter-argument is that the Rules were never really optional; the compiler just made the consequences visible. Both camps are partially right, and the tension shows up most sharply in older libraries that predate the Rules being written down.

"use no memo" as permanent technical debt

The escape hatch is easy to reach for, and that worries the people who have watched codebases accumulate similar markers over years. The usual comparisons are @ts-ignore and // eslint-disable: useful release valves that age poorly when they stop being revisited. Others argue the directive is load-bearing precisely because it lets you ship without a full refactor, and that treating it as a permanent marker is misuse, not a property of the feature. What nobody disputes anymore is that "use no memo" count is a legitimate codebase-health metric.

Compiler vs runtime optimizers

For most apps the compiler is enough. For list-heavy specialist UIs (trading dashboards, log viewers, spreadsheets) a block-based reconciler like Million.js still measurably wins. The two layers solve different problems: the compiler reduces how often components re-render, runtime optimizers change how fast each re-render is. Some teams run both. The interaction between them is fine in practice but not well-documented anywhere in a way that feels settled. I suspect this is where someone will eventually write the definitive post.

What Comes Next

The public signals from the React team, Meta engineering posts, and active RFCs point in five directions. None of these are promises; treat them as directional.

Finer-grained compilation control

The current modes are coarse: on, off, annotation-driven. What teams seem to want is per-component hints, compile aggressively for this one, conservatively for that one, never for this legacy island, plus better tooling to see what the compiler actually did. Expect directives that narrow the current all-or-nothing tradeoff.

Compiler-aware Server Components

RSC already ships memoization-friendly output for client islands. The next step is tightening the serialization boundary, so the payload the browser has to hydrate gets smaller when the compiler can prove that a value does not need to cross. This is where the biggest performance wins for real apps live: reducing hydration cost matters more than reducing re-render counts, because hydration is what users feel on cold loads.

useEvent converging

The long-discussed primitive for stable event callbacks that read the latest state has been in RFC for years. The compiler's purity analysis is the piece that makes its semantics tractable to verify, which is why the proposal stalled before the compiler existed. Expect it to ship in some form.

React Native

Native view diffing is more expensive than web reconciliation, so auto-memoization matters disproportionately there. Expo has shipped compiler support; React Native itself has been slower. The pressure for parity is growing because the benefit per component is larger.

Developer tooling

The missing piece is a DevTools panel that shows, per component, what the compiler did and why. Right now the ESLint plugin tells you what was skipped; a visualizer for what was optimized would make the compiler's work legible in a way that feels like the RSC tree visualizer. This is not on any public roadmap I know of, but it is the obvious next tool, and the kind of thing a community plugin can ship before the React team does.

My Take on the Road Ahead

Eighteen months in, the compiler's legacy is not going to be its benchmark numbers. It will be the category of bug it retired. "You forgot a useCallback dependency" is not a conversation anymore. Nor is "should this component be memoized?" The answer is yes, always, and the compiler handles it.

The more interesting question is what the Rules of React become now that a compiler enforces them. For years they were a list of things good React code happened to do; now they are a build-time contract that rejects code that does not comply. Some of the ecosystem's oldest libraries are still adjusting. The next year of the compiler's story is probably less about the compiler itself and more about what the library ecosystem looks like after everything has been made compiler-safe.

For new projects, the decision is easy: turn it on. For existing projects, the decision is whether to fix your oldest code or ship "use no memo" as a commitment device. Both are legitimate tradeoffs; both are worth being deliberate about.

When you see a useMemo or useCallback in 2026, treat it the way you would treat a manual for loop in modern JavaScript. Usually fine. Occasionally necessary. Mostly a sign that the author wrote this before better tools existed.


S
글쓴이
Sascha Becker
다른 글