Every React developer who has worked on a non-trivial application has confronted the same ritualistic burden: wrapping callbacks in useCallback, caching derived values with useMemo, and shielding child components behind React.memo. React Compiler eliminates that tax entirely through automatic static analysis and memoization at build time.
Table of Contents
- The Performance Tax Every React Developer Pays
- What Is React Compiler and How Does It Work?
- Setting Up React Compiler in a Project
- Before and After: Practical Refactoring Examples
- Understanding the Rules of React for Compiler Compatibility
- Performance Comparison: Manual vs. Compiler-Optimized
- Implementation Checklist: Adopting React Compiler in Your Project
- Limitations, Edge Cases, and When You Still Need Manual Control
- The End of Memoization as Manual Labor
The Performance Tax Every React Developer Pays
Every React developer who has worked on a non-trivial application has confronted the same ritualistic burden: wrapping callbacks in useCallback, caching derived values with useMemo, and shielding child components behind React.memo. This manual memoization work is a performance tax every codebase pays when it cares about rendering efficiency. React Compiler eliminates that tax entirely through automatic static analysis and memoization at build time.
React's rendering model has always re-executed component functions top-down when state changes. Without intervention, a parent state update triggers re-renders in every child, even when their props haven't changed. The community's answer for years has been manual memoization primitives, but these introduce their own costs. Incorrect dependency arrays produce stale closures. Defensive over-memoization adds memory overhead and code noise without improving performance. Every time component logic changes, developers must audit and update each dependency array — work that costs time without shipping features.
React Compiler changes this equation. It is a build-time tool that analyzes component code, understands data flow, and inserts granular memoization automatically. By the end of this tutorial, readers will understand the compiler's core mechanism, set it up in a real project across common toolchains, refactor existing code to take advantage of it, and know exactly where manual control is still required.
What Is React Compiler and How Does It Work?
The Problem: Manual Memoization at Scale
React provides three primary manual performance primitives. useMemo caches the result of an expensive computation between renders. useCallback caches a function definition so that child components receiving it as a prop can skip re-rendering. React.memo wraps an entire component to perform a shallow comparison of props before deciding whether to re-render.
In practice, these primitives create a web of fragile dependencies. A single missing entry in a useCallback dependency array produces a stale closure bug whose symptoms — a handler referencing an outdated state value, a filter operating on data from two renders ago — appear far from the incorrect dependency array. Over-memoization, where developers wrap everything defensively, adds memory overhead and code noise without improving performance. As components evolve, synchronizing dependency arrays with actual data flow costs time without shipping features.
Consider a typical parent/child component pair with manual memoization. Note that while the handleSelect callback below happens to be safe with an empty dependency array (because setSelectedId is a stable state setter), identifying when an empty array is safe versus when it introduces a stale closure is itself the maintenance burden this pattern creates:
import React, { useState, useMemo, useCallback } from 'react';
const ExpensiveChild = React.memo(({ items, onSelect }) => {
console.log('ExpensiveChild rendered');
return (
<ul>
{items.map((item) => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
});
function ProductList({ products }) {
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState(null);
const filteredProducts = useMemo(
() => products.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())),
[products, query]
);
const handleSelect = useCallback(
(id) => {
setSelectedId(id);
},
[]
);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<p>Selected: {selectedId}</p>
<ExpensiveChild items={filteredProducts} onSelect={handleSelect} />
</div>
);
}
This is 35 lines of code where roughly a third exists solely for performance management. In a codebase with hundreds of components, this pattern multiplies into thousands of lines of memoization boilerplate.
Static Analysis + Automatic Memoization: The Core Mechanism
React Compiler operates as a Babel plugin that runs at build time. Next.js integration uses a different internal compilation path, but the principle is identical. During compilation, the plugin reads each component function, constructs an internal model of data flow, and determines which values, callbacks, and component subtrees benefit from memoization. The output is a rewritten component that includes cache slots and conditional checks serving the same purpose as manual useMemo, useCallback, and React.memo, but with more granular precision.
The analysis is grounded in the Rules of React — React's documented constraints for component and hook behavior. These rules stipulate that components must behave as pure functions of their props and state during rendering. Side effects must be confined to useEffect, useLayoutEffect, or event handlers. Values must not be mutated during the render path. When code follows these rules, the compiler can safely reason about which values change between renders and which do not. The compiler skips code that violates these rules rather than compiling it incorrectly. Depending on configuration, the compiler emits diagnostic notes for skipped components; check build output and use the ESLint plugin to surface violations.
This distinction matters: zero analysis happens at runtime. The compiler produces transformed JavaScript at build time. The output code simply checks cached values against current inputs and returns cached results when inputs haven't changed.
What the Compiler Outputs Behind the Scenes
The following is a simplified illustration of the caching mechanism — not actual compiler output. Internal slot types, sentinel values, and slot counts differ in real output. Use the React Compiler Playground to inspect real compiler output.
When the compiler processes the ProductList component from the earlier example, the mechanism follows this general pattern. Note that the compiler uses internal sentinel values (not string literals) to detect first-render conditions:
function ProductList({ products }) {
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState(null);
// Illustrative cache slots — NOT a public API.
// Real compiler uses react/compiler-runtime internals (e.g.,
// import { c as _c } from 'react/compiler-runtime').
// Slot count is statically determined by the compiler.
const UNINITIALIZED = Symbol('uninitialized');
const $ = new Array(11).fill(UNINITIALIZED); // 11 slots: 0–10
let filteredProducts;
if ($[0] !== products || $[1] !== query) {
filteredProducts = products.filter((p) =>
p.name.toLowerCase().includes(query.toLowerCase())
);
$[0] = products;
$[1] = query;
$[2] = filteredProducts;
} else {
filteredProducts = $[2];
}
// Compiler-generated cache slot for the callback.
// The compiler uses an internal sentinel (Symbol-like), not a string.
let handleSelect;
if ($[3] === UNINITIALIZED) {
handleSelect = (id) => {
setSelectedId(id);
};
$[3] = true; // mark as initialized
$[4] = handleSelect;
} else {
handleSelect = $[4];
}
// Compiler-generated cache slot for the child element
let child;
if ($[5] !== filteredProducts || $[6] !== handleSelect) {
child = <ExpensiveChild items={filteredProducts} onSelect={handleSelect} />;
$[5] = filteredProducts;
$[6] = handleSelect;
$[7] = child;
} else {
child = $[7];
}
// Compiler caches the full JSX output using separate slots
let output;
if ($[8] !== query || $[9] !== selectedId) {
output = (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<p>Selected: {selectedId}</p>
{child}
</div>
);
$[8] = query;
$[9] = selectedId;
$[10] = output;
} else {
output = $[10];
}
return output;
}
Note: This pseudocode is illustrative only and cannot be run. The cache mechanism (_compilerCache / _c) is an internal compiler runtime detail, not a public API. The Babel plugin injects the runtime import automatically — no manual import is needed.
Notice the granularity. Beyond caching the filtered list and the callback, the compiler caches the JSX element creation for the child component. If the child's props haven't changed, React receives the same element reference and skips reconciliation for that subtree entirely. This is more granular than what most developers achieve manually, because few developers think to memoize the JSX element itself.
Setting Up React Compiler in a Project
Prerequisites and Compatibility Check
React Compiler works with React 19, which is the recommended target. For projects still on React 17 or 18, teams can adopt it by additionally installing the react-compiler-runtime package, which provides the runtime helpers the compiled output depends on. The Babel plugin injects the runtime import automatically — no manual import is needed in your component code. React 18.2.0+ is recommended for React 17/18 users; consult the react-compiler-runtime package documentation for the exact minimum supported version.
Before installing, developers should assess their codebase's readiness by running:
# Pin to a specific version for reproducible builds.
# Replace 0.x.x with the version from https://react.dev/learn/react-compiler
npx react-compiler-healthcheck@0.x.x
Confirm the package name against the official React documentation before execution, as the tooling is under active development.
This command scans the project and reports how many components the compiler can process, identifies patterns that violate the Rules of React, and flags third-party libraries that cause issues. It also checks for incompatible patterns such as mutation during render.
The ESLint plugin eslint-plugin-react-compiler should be installed alongside the compiler. It surfaces Rules of React violations directly in the editor, allowing developers to fix incompatible code before the compiler skips it.
Installation and Babel Configuration
For a Vite-based project, the setup proceeds as follows:
# Pin to a specific version for reproducible builds.
# Replace 0.x.x with the version from https://react.dev/learn/react-compiler
npm install -D babel-plugin-react-compiler@0.x.x
npm install -D eslint-plugin-react-compiler@0.x.x
In a Vite project using @vitejs/plugin-react (the Babel-based variant), the Babel plugin is configured within vite.config.js:
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: [
['babel-plugin-react-compiler', {}],
],
},
}),
],
});
Note: This configuration applies to @vitejs/plugin-react. If using @vitejs/plugin-react-swc, Babel plugin configuration is not directly supported — use a standalone babel.config.js approach instead.
For Next.js 15.x projects, the compiler is configured in next.config.js (CJS) or next.config.mjs (ESM — replace module.exports = with export default). Verify your exact version supports experimental.reactCompiler in the Next.js changelog:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
module.exports = nextConfig;
Note: If your project uses next.config.mjs (ESM, the default for new Next.js 15 projects), replace module.exports = nextConfig with export default nextConfig.
For projects using a standard Babel configuration:
// babel.config.cjs ← use .cjs extension for projects with "type":"module" in package.json
// OR name this file babel.config.js only if package.json does NOT have "type":"module"
module.exports = {
plugins: [
['babel-plugin-react-compiler', {}],
],
};
// For ESM projects (package.json "type":"module"), you may alternatively use
// babel.config.mjs with:
// export default {
// plugins: [
// ['babel-plugin-react-compiler', {}],
// ],
// };
Gradual Adoption with Opt-In Mode
For large existing codebases, enabling the compiler globally on day one is risky. The compilationMode: "annotation" option restricts the compiler to only process components that explicitly opt in:
// babel.config.cjs
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
compilationMode: 'annotation',
}],
],
};
Individual components opt in using the "use memo" directive (verify the exact directive string against your installed babel-plugin-react-compiler version, as this API was in flux during the beta period):
function SearchResults({ data, query }) {
"use memo";
const filtered = data.filter((item) =>
item.title.toLowerCase().includes(query.toLowerCase())
);
return (
<ul>
{filtered.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
);
}
The "use memo" directive at the top of the function body signals the compiler to process this specific component. This allows teams to start with well-tested components, validate behavior through existing test suites, and expand coverage incrementally.
Before and After: Practical Refactoring Examples
Eliminating useCallback and useMemo
The most common transformation developers will encounter is the removal of useCallback and useMemo from components that use them for standard prop-passing and derived data patterns.
Before (manual memoization):
import { useState, useMemo, useCallback } from 'react';
function SearchPanel({ items }) {
const [query, setQuery] = useState('');
const [sortOrder, setSortOrder] = useState('asc');
const filteredAndSorted = useMemo(() => {
const filtered = items.filter((item) =>
item.name.toLowerCase().includes(query.toLowerCase())
);
// FIX: spread to avoid mutating the filtered intermediate array
return [...filtered].sort((a, b) =>
sortOrder === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
);
}, [items, query, sortOrder]);
const handleQueryChange = useCallback((e) => {
setQuery(e.target.value);
}, []);
const toggleSort = useCallback(() => {
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
}, []);
return (
<div>
<input value={query} onChange={handleQueryChange} />
<button onClick={toggleSort}>Sort: {sortOrder}</button>
<ResultsList items={filteredAndSorted} />
</div>
);
}
After (compiler-optimized):
import { useState } from 'react';
function SearchPanel({ items }) {
const [query, setQuery] = useState('');
const [sortOrder, setSortOrder] = useState('asc');
const filtered = items.filter((item) =>
item.name.toLowerCase().includes(query.toLowerCase())
);
const sorted = [...filtered].sort((a, b) =>
sortOrder === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<button onClick={() => setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'))}>
Sort: {sortOrder}
</button>
<ResultsList items={sorted} />
</div>
);
}
The component reads like straightforward JavaScript. No dependency arrays to maintain, no wrapper hooks, no opportunity for stale closure bugs.
The compiler handles caching the filter/sort result and stabilizing the callback references. Note the use of [...filtered].sort(...) rather than filtered.sort(...) — calling .sort() directly would mutate the filtered array in place, which can corrupt the compiler's cache for that intermediate value.
Removing React.memo Wrappers
Before (wrapped in React.memo):
const UserCard = React.memo(function UserCard({ user, onEdit }) {
return (
<div className="card">
<h3>{user.name}</h3>
<p>{user.email}</p>
<button onClick={() => onEdit(user.id)}>Edit</button>
</div>
);
});
After (plain component):
// Requires automatic JSX transform (React 17+ or configured Babel)
function UserCard({ user, onEdit }) {
return (
<div className="card">
<h3>{user.name}</h3>
<p>{user.email}</p>
<button onClick={() => onEdit(user.id)}>Edit</button>
</div>
);
}
The compiler's output caches the JSX element for UserCard at the call site, provided the parent component is also successfully compiled. If the parent is skipped, retain React.memo on performance-critical children. When the parent re-renders but the user and onEdit references are unchanged, React reuses the cached element. This achieves the same skip behavior as React.memo without requiring the wrapper at the component definition.
Handling Derived State and Expensive Computations
The compiler identifies derived computations and caches them based on their input dependencies. However, there are edge cases where manual control remains necessary. Components that subscribe to external mutable stores (a Redux store, a WebSocket connection, a browser API) need useSyncExternalStore to ensure React is aware of changes that originate outside its own state management. The compiler does not insert subscriptions to external systems; it only caches values derived from props and state.
Understanding the Rules of React for Compiler Compatibility
Pure Rendering and Side-Effect Boundaries
The compiler's correctness depends on a single foundational contract: components must be pure functions of their props and state during the render phase. If a component reads from a mutable global variable during render, the compiler will cache the result of that read and return stale data on subsequent renders. If a component mutates an object that it received as a prop, the compiler's caching assumptions break down because it cannot detect mutations through reference equality checks.
Side effects must be placed in useEffect or useLayoutEffect for effects tied to the render lifecycle, or in event handlers for effects triggered by user interaction. This has always been the documented expectation in React, but the compiler enforces it as a hard requirement rather than a suggestion.
Common Violations and How to Fix Them
The most frequent violation is mutating an array or object during render:
Violation:
// ⚠️ VIOLATION EXAMPLE — DO NOT COPY INTO PRODUCTION CODE
// This mutates the prop array directly during render,
// breaking compiler caching and corrupting parent state.
function TagList({ tags }) {
tags.sort((a, b) => a.localeCompare(b)); // BUG: mutates prop
return (
<ul>
{tags.map((tag) => (
<li key={tag}>{tag}</li>
))}
</ul>
);
}
Corrected:
function TagList({ tags }) {
// Creates a new sorted copy, preserving the original
const sortedTags = [...tags].sort((a, b) => a.localeCompare(b));
return (
<ul>
{sortedTags.map((tag) => (
<li key={tag}>{tag}</li>
))}
</ul>
);
}
The corrected version creates a new array via the spread operator before sorting. The original tags prop is never mutated, and the compiler can safely cache sortedTags based on the reference identity of tags. The ESLint plugin (eslint-plugin-react-compiler) catches this class of mutation violation and reports it directly in the development environment, provided the plugin is correctly configured in your ESLint setup.
Performance Comparison: Manual vs. Compiler-Optimized
What to Measure and How
React DevTools Profiler provides flamegraph visualizations that show exactly which components re-rendered during an interaction and how long each render took. The built-in <Profiler> component with its onRender callback provides programmatic access to render timing data. actualDuration measures time spent rendering. baseDuration estimates the cost of an un-memoized render of the entire subtree.
Track three metrics: render count (how many times a component re-renders during an interaction), render duration (how long each render takes), and commit frequency (how often React commits changes to the DOM).
Qualitative Comparison
The following table provides a qualitative comparison of optimization strategies. These are not measured benchmarks; profile your specific application to obtain empirical data. Actual performance gains depend on component complexity, data size, and render frequency. The comparison is for a representative component tree with a parent component, a search input, a derived filtered list, and a list of child components:
| Metric | No Manual Memoization | Manual Memoization | React Compiler |
|---|---|---|---|
| Child re-renders per keystroke | All children re-render | Only affected children re-render | Only affected children re-render |
| Callback reference stability | New reference every render | Stable via useCallback | Stable via compiler cache |
| Derived value recalculation | Every render | Only when dependencies change | Only when dependencies change |
| JSX element caching | None | Not typically done manually | Automatic per-element caching |
| Developer effort | None | High (manual wrappers, dependency arrays) | None (write idiomatic React) |
| Risk of stale closure bugs | N/A | Significant with incorrect deps | None (compiler tracks dependencies) |
| Bundle size impact | Baseline | Slightly larger (wrapper code); typically single-digit percentage increase — profile your bundle to confirm | Slightly larger (cache slot code); typically single-digit percentage increase — profile your bundle to confirm |
The compiler can sometimes outperform manual memoization because it caches at a more granular level. Most developers memoize at the component boundary with React.memo or at the value level with useMemo, but the compiler additionally caches individual JSX element creation. This means that even within a single component's render output, unchanged subtrees can be reused without reconciliation.
Implementation Checklist: Adopting React Compiler in Your Project
- Verify React version is 19 (recommended) or 17+ with
react-compiler-runtimeinstalled. If on React 17 or 18, run:npm install react-compiler-runtime - Run
npx react-compiler-healthcheck@0.x.xon your codebase and review the report (replace0.x.xwith the version matching your compiler plugin) - Install and configure
eslint-plugin-react-compilerin your ESLint setup - Fix all ESLint violations to ensure Rules of React compliance across the codebase
- Install
babel-plugin-react-compileras a dev dependency (with a pinned version) - Configure the Babel/Vite/Next.js build pipeline with the compiler plugin
- Start with
compilationMode: "annotation"for gradual, controlled rollout. Opt in components that re-render frequently during user interactions, or that sit at the root of deep subtrees, using the"use memo"directive (verify the directive string against your installed compiler version) - Profile before and after with React DevTools Profiler to validate improvements
- Remove manual
useMemo,useCallback, andReact.memoonce compiler coverage is confirmed — verify coverage by inspecting build output for compiler-generated cache slots (e.g.,_c(or equivalent) in the compiled component code, or by checking that the ESLint plugin reports no skipped-component warnings - Switch to full compilation mode (remove the
compilationModerestriction) - Update team coding guidelines to remove memoization from code review checklists
Limitations, Edge Cases, and When You Still Need Manual Control
Current Limitations
The compiler does not process class components. Only function components and hooks are analyzed and transformed. When code violates the Rules of React, the compiler skips it rather than compiling it incorrectly, which means developers may not realize a component runs without optimization unless they check the compiler's output or use the ESLint plugin. Depending on configuration, the compiler emits diagnostic notes for skipped components; check build output and use the ESLint plugin to surface violations. Third-party hooks that internally contain hidden side effects (reading from mutable external state, performing I/O during render) produce incorrect behavior when the compiler caches their return values. The compiler continues to evolve, and edge cases in complex hook compositions — e.g., hooks that conditionally call other hooks or rely on closure identity across nested custom hooks — will likely surface as adoption widens.
When Manual Optimization Still Applies
useSyncExternalStore remains necessary for subscribing to external mutable stores such as Redux, Zustand, or browser APIs like navigator.onLine. The compiler cannot insert subscriptions to state systems it does not control. Web Workers, Canvas rendering, and imperative DOM operations via refs involve side effects that fall outside the compiler's scope. Performance-critical animations that depend on requestAnimationFrame timing require manual control over render cycles and cannot be optimized through memoization alone.
The End of Memoization as Manual Labor
Write correct React that follows the Rules of React, and let the compiler handle optimization.
React Compiler automates static analysis and memoization at build time, transforming an entire category of manual performance work into a solved problem. The shift in developer responsibility is clear: write correct React that follows the Rules of React, and let the compiler handle optimization. Teams can begin adoption today using the implementation checklist above, starting with annotation mode and expanding as confidence grows. For further details, see the official React Compiler documentation.

