React Compiler is a build-time optimizer. It reads components and Hooks, proves which values can be reused, and emits cache checks that React executes at runtime.
It does not change React's rendering model. A state update still schedules a render, rendering still computes a candidate tree, and committing still publishes it. The compiler makes some of that render work reusable.
source component
→ analyze data flow and effects
→ discover reactive scopes
→ emit dependency checks and cache slots
→ run the transformed component in ReactThis note follows Lydia Hallie's React Compiler Internals. The talk explains the pipeline clearly, but its release-status and setup slides are from 2025. React Compiler is now stable, remains optional, and the current React Compiler documentation is the source of truth.
For the runtime half — Fibers, lanes, rendering, commits, and bailouts — see Understanding React in Depth.
1. The Problem Is Repeated Render Work
When a component's state changes, React renders that component and, by default, its children. A child can therefore repeat a calculation even when the input to that calculation has not changed.
function UserStats({ users }: { users: User[] }) {
const activeUsers = users.filter((user) => user.active)
return <p>{activeUsers.length} active users</p>
}
function App({ users }: { users: User[] }) {
const [sort, setSort] = useState<"asc" | "desc">("asc")
return (
<>
<SortButton value={sort} onChange={setSort} />
<UserStats users={users} />
</>
)
}Changing sort renders App again. Without a bailout, UserStats executes again and filters users, even though its users prop is the same reference.
Manual memoization can add that bailout:
const UserStats = memo(function UserStats({ users }: { users: User[] }) {
const activeUsers = useMemo(
() => users.filter((user) => user.active),
[users]
)
return <p>{activeUsers.length} active users</p>
})This works, but now the developer owns two boundaries and a dependency list. Add department to the filter but not to [users], and the cache returns stale data.
React Compiler solves the maintenance problem: infer dependencies from the program, create cache boundaries, and preserve the component's meaning.
Failure: treating compilation as a semantic repair. The compiler cannot make an impure component pure or make a missing state update correct. Memoization may only skip work whose result would have been the same.
2. What the Compiler Actually Memoizes
The compiler primarily improves update performance in two ways:
- It can reuse JSX so unchanged children do not receive cascading re-renders.
- It can reuse calculations performed inside components and Hooks when their reactive inputs are unchanged.
Those boundaries can be finer than a hand-written useMemo.
function UserStats({ users }: { users: User[] }) {
const activeUsers = users.filter((user) => user.active)
const count = activeUsers.length
return <p>{count} active users</p>
}The compiler can discover one scope for filtering by users and another for creating JSX from count. If users changes but its active count does not, the filter scope runs while the JSX scope can still reuse its output.
The boundaries are local:
- It analyzes React components and Hooks, not every JavaScript function in the program.
- A cache belongs to one component or Hook instance. Two component instances do not share one result cache.
- A costly helper called from several components may still need a cache outside React if its result should be shared.
- It optimizes updates. It cannot avoid the first calculation needed to produce the initial UI.
The result is often called fine-grained reactivity, but React has not become a signal runtime. React still renders components. Compiler-generated checks let parts of those renders reuse previous values.
3. The Compilation Pipeline
The source passes through many compiler stages. The useful mental model is:
JavaScript / TypeScript source
→ HIR atomic instructions and control flow
→ SSA one assignment per variable version
→ type inference what kind of value is this?
→ effect analysis how can this operation touch data?
→ reactive analysis can this value change between renders?
→ scope discovery which instructions cache together?
→ code generation dependency checks and cache slotsHIR, SSA, effect kinds, and pass names are implementation details. Application code should depend on the Rules of React, not a particular compiler pass.
The React Compiler Playground is the best way to inspect the current pipeline. It shows whether a function compiled and the transformed output without making an application's build artifacts part of its API.
4. HIR Makes Data Flow Explicit
JavaScript syntax is convenient for humans but too large and flexible to reason about as one block. The compiler lowers it to a High-level Intermediate Representation.
Conceptually, this:
const activeUsers = users.filter((user) => user.active)
const count = activeUsers.length
return <p>{count}</p>becomes a sequence closer to:
load users
load users.filter
create filter callback
call filter(users, callback) → activeUsers
load activeUsers.length → count
create JSX("p", count) → result
return resultEach instruction has inputs, an output, and a place in control flow. That lets later passes answer:
- Which instruction produced
count? - Which source values can reach the returned JSX?
- Does an operation only read a value, or might it mutate it?
- Which instructions need to rerun if
userschanges?
HIR is still high-level enough to retain React meaning. The compiler is not optimizing machine instructions; it is proving when JavaScript and JSX values can safely be reused.
5. SSA Separates Reassignments
Reassignment makes data flow ambiguous because one source name can mean different values along different paths.
let activeUsers = users.filter((user) => user.active)
if (filters.limitResults) {
activeUsers = activeUsers.slice(0, 10)
}
return <UserList users={activeUsers} />Which assignment reaches UserList depends on runtime control flow. Static Single Assignment gives each assignment a separate version:
activeUsers_1 = filter(users)
if filters.limitResults:
activeUsers_2 = slice(activeUsers_1, 0, 10)
activeUsers_3 = φ(activeUsers_2, activeUsers_1)
return UserList(activeUsers_3)The φ notation is the conceptual join: choose the version produced by the path that actually ran. The source variable did not become immutable. The compiler created an internal form where every version has one definition.
Now later passes can follow each value without asking which meaning of activeUsers a line refers to.
6. Effects and Reactivity Answer Different Questions
After data flow is explicit, the compiler needs two different analyses.
Effect analysis asks how an operation interacts with data. The talk illustrates:
- read: inspect a property such as
users.length; - store: assign a newly produced value;
- capture: use a parameter or value from an outer scope;
- mutate: possibly change an existing value;
- freeze: establish that a value will not be modified afterward.
These labels summarize compiler reasoning; they are not JavaScript effects or APIs. A read is easier to cache than an unknown mutation. A capture introduces a dependency the cache must track.
Reactive analysis asks whether a value may differ between renders.
props and Hook results begin reactive
↓
users → users.filter → activeUsers → count → JSXFunction parameters are reactive because props can change. Values returned by Hooks such as useState and useContext are reactive. When a reactive value flows into an operation, the output may become reactive too.
The distinction matters:
- An operation can be pure but still depend on reactive input.
- An operation can read stable input and need no reactive cache boundary.
- An unknown mutation can make a seemingly useful cache unsafe.
The compiler needs both answers: is reuse safe, and what change invalidates it?
Failure: mutating users during render. Reference equality can no longer represent whether its contents changed, and the component already violates React's immutable snapshot model.
7. Reactive Scopes Become Cache Slots
Scope discovery groups instructions by the reactive values that invalidate them.
scope A
dependency: users
output: activeUsers
scope B
dependency: count
output: <p>{count}</p>Operations that change together belong together. Operations with independent dependencies get independent scopes. Code generation then allocates slots for dependencies and outputs.
Current generated output uses the compiler runtime. A simplified shape looks like:
import { c as _c } from "react/compiler-runtime"
function UserStats({ users }: { users: User[] }) {
const $ = _c(4)
let activeUsers
if ($[0] !== users) {
activeUsers = users.filter((user) => user.active)
$[0] = users
$[1] = activeUsers
} else {
activeUsers = $[1]
}
const count = activeUsers.length
let result
if ($[2] !== count) {
result = <p>{count} active users</p>
$[2] = count
$[3] = result
} else {
result = $[3]
}
return result
}The real output and sentinel checks can change. The durable model is:
compare dependencies
→ changed: calculate and store dependency + output
→ unchanged: reuse cached outputThe compiler does not literally insert a forest of source-level useMemo calls. The talk calls the primitive useMemoCache; minified output often exposes it as _c. Current documentation shows c imported from react/compiler-runtime.
8. The Compiler Is Conservative
Automatic memoization is valid only when components and Hooks obey the Rules of React:
- Components and Hooks are pure and idempotent during render.
- Props, state, Hook arguments, and values passed to JSX are immutable snapshots.
- React calls components; application code does not invoke component functions directly.
- Hooks run at the top level and only from React functions.
Conditional Hooks, render-time side effects, mutated props, class components, and JavaScript patterns the compiler cannot prove safe may not be optimized.
This is a local skip, not normally an all-or-nothing verdict. The compiler can skip one component or Hook and continue optimizing others. The uncompiled function keeps its ordinary React behavior.
function LegacyGrid() {
"use no memo" // TODO: remove after upgrading the incompatible grid library
return <ThirdPartyGrid />
}"use no memo" is a temporary escape hatch, not a performance strategy. Document why it exists, fix the underlying incompatibility, and remove it.
Failure: adding "use no memo" to silence every compiler diagnostic. That preserves behavior by discarding optimization coverage while leaving the Rules of React violations in place.
9. Adopt the Stable Compiler Gradually
React Compiler is stable and tested in production, but it is still an optional build step. Adoption depends on the health of the codebase.
For a Babel pipeline:
pnpm add -D babel-plugin-react-compiler@latestmodule.exports = {
plugins: [
"babel-plugin-react-compiler", // Must run before other transforms.
],
}The compiler needs original source information, so its Babel plugin runs first.
Next.js has a direct option:
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
reactCompiler: true,
}
export default nextConfigLarge applications can begin with annotation mode:
const nextConfig: NextConfig = {
reactCompiler: {
compilationMode: "annotation",
},
}function ExpensiveList() {
"use memo"
// Only annotated components and Hooks compile in annotation mode.
}Other rollout choices include Babel directory overrides and runtime gating. React 17 and 18 are supported, but require the matching target and react-compiler-runtime; React 19 is the default target.
Use current tooling to verify adoption:
- Enable
eslint-plugin-react-hooks@latestwith itsrecommended-latestpreset. Compiler diagnostics identify functions that will be skipped. - Run behavior tests. Compilation must not change what users observe.
- Profile important interactions. Automatic memoization is not proof of a useful speedup.
- Look for the official Memo ✨ badge in React DevTools.
- Inspect a small sample in the Compiler Playground or build output.
The health-check command shown in the 2025 talk is no longer the recommended path. Current React docs direct codebases to the ESLint integration.
10. Manual Memoization Is Still an Escape Hatch
Do not mechanically delete every memo, useMemo, or useCallback after enabling the compiler.
- Existing memoization can affect compiler output. Leave it in place unless tests and profiling justify removal.
- New code should normally rely on compiler inference.
useMemoanduseCallbackremain useful when identity is part of an integration contract, especially when stabilizing an Effect dependency.- A cache shared across component instances belongs outside compiler-generated component caches.
- Memoization still has memory and comparison costs. Faster is an empirical result, not a syntax choice.
The shortest accurate model:
HIR exposes.
SSA separates.
Effects constrain.
Reactivity propagates.
Scopes group.
Codegen caches.
React renders.Primary references: Lydia Hallie's React Compiler Internals, React's docs on the compiler introduction, installation, incremental adoption, directives, configuration, and the Rules of React. For Next.js, use the current reactCompiler configuration.