React Compiler (React Forget) — Automatic memoization at build time
Recipe
// Step 1: Install the compiler
// npm install -D babel-plugin-react-compiler
// Step 2: Add to your Next.js config (next.config.ts)
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
export default nextConfig;
// Step 3: Remove manual memoization — the compiler handles it
// BEFORE: Manual memoization
function ProductList({ products, category }: Props) {
const filtered = useMemo(
() => products.filter((p) => p.category === category),
[products, category]
);
const handleClick = useCallback((id: string) => {
selectProduct(id);
}, [selectProduct]);
return <MemoizedGrid items={filtered} onClick={handleClick} />;
}
const MemoizedGrid = memo(Grid);
// AFTER: Compiler auto-memoizes — cleaner code, same performance
function ProductList({ products, category }: Props) {
const filtered = products.filter((p) => p.category === category);
const handleClick = (id: string) => selectProduct(id);
return <Grid items={filtered} onClick={handleClick} />;
}When to reach for this: When starting a new React 19+ project or migrating an existing one. The compiler eliminates boilerplate from useMemo, useCallback, and memo while maintaining equivalent or better performance.
Working Example
// ---- BEFORE: Manual memoization with 15 lines of boilerplate ----
import { memo, useMemo, useCallback, useState } from "react";
interface User {
id: string;
name: string;
role: "admin" | "user";
lastActive: Date;
}
function UserDashboard({ users }: { users: User[] }) {
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<"all" | "admin" | "user">("all");
const [selectedId, setSelectedId] = useState<string | null>(null);
const filteredUsers = useMemo(
() =>
users
.filter((u) => roleFilter === "all" || u.role === roleFilter)
.filter((u) => u.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime()),
[users, roleFilter, search]
);
const handleSelect = useCallback((id: string) => {
setSelectedId(id);
}, []);
const stats = useMemo(
() => ({
total: users.length,
admins: users.filter((u) => u.role === "admin").length,
active: users.filter((u) => {
const hourAgo = Date.now() - 3600_000;
return u.lastActive.getTime() > hourAgo;
}).length,
}),
[users]
);
return (
<div>
<MemoizedStatsBar stats={stats} />
<input value={search} onChange={(e) => setSearch(e.target.value)} />
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value as "all" | "admin" | "user")}
>
<option value="all">All</option>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
<MemoizedUserList
users={filteredUsers}
selectedId={selectedId}
onSelect={handleSelect}
/>
</div>
);
}
const MemoizedStatsBar = memo(function StatsBar({
stats,
}: {
stats: { total: number; admins: number; active: number };
}) {
return (
<div className="flex gap-4">
<span>Total: {stats.total}</span>
<span>Admins: {stats.admins}</span>
<span>Active: {stats.active}</span>
</div>
);
});
const MemoizedUserList = memo(function UserList({
users,
selectedId,
onSelect,
}: {
users: User[];
selectedId: string | null;
onSelect: (id: string) => void;
}) {
return (
<ul>
{users.map((user) => (
<li
key={user.id}
onClick={() => onSelect(user.id)}
className={user.id === selectedId ? "bg-blue-100" : ""}
>
{user.name} ({user.role})
</li>
))}
</ul>
);
});
// ---- AFTER: React Compiler — same performance, zero boilerplate ----
function UserDashboard({ users }: { users: User[] }) {
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<"all" | "admin" | "user">("all");
const [selectedId, setSelectedId] = useState<string | null>(null);
const filteredUsers = users
.filter((u) => roleFilter === "all" || u.role === roleFilter)
.filter((u) => u.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime());
const stats = {
total: users.length,
admins: users.filter((u) => u.role === "admin").length,
active: users.filter((u) => {
const hourAgo = Date.now() - 3600_000;
return u.lastActive.getTime() > hourAgo;
}).length,
};
return (
<div>
<StatsBar stats={stats} />
<input value={search} onChange={(e) => setSearch(e.target.value)} />
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value as "all" | "admin" | "user")}
>
<option value="all">All</option>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
<UserList
users={filteredUsers}
selectedId={selectedId}
onSelect={(id: string) => setSelectedId(id)}
/>
</div>
);
}
function StatsBar({ stats }: { stats: { total: number; admins: number; active: number } }) {
return (
<div className="flex gap-4">
<span>Total: {stats.total}</span>
<span>Admins: {stats.admins}</span>
<span>Active: {stats.active}</span>
</div>
);
}
function UserList({
users,
selectedId,
onSelect,
}: {
users: User[];
selectedId: string | null;
onSelect: (id: string) => void;
}) {
return (
<ul>
{users.map((user) => (
<li
key={user.id}
onClick={() => onSelect(user.id)}
className={user.id === selectedId ? "bg-blue-100" : ""}
>
{user.name} ({user.role})
</li>
))}
</ul>
);
}What this demonstrates:
- The compiler-optimized version removes 6 hook calls (
useMemox2,useCallbackx1,memox2 wrappers) and 15 lines of boilerplate - Performance is equivalent because the compiler inserts memoization at the AST level during build
- Code is more readable and maintainable without manual dependency arrays
- No behavior changes — the compiler only adds optimizations, never changes semantics
Deep Dive
How It Works
- Build-time transformation — The React Compiler is a Babel plugin that analyzes your component code at build time. It identifies values, functions, and JSX elements that can be safely cached between renders.
- Automatic dependency tracking — The compiler statically analyzes which variables each expression depends on, then wraps those expressions in memoization with the correct dependency arrays. This is more reliable than manually writing dependency arrays.
- Component-level memoization — The compiler wraps component return values so that if props and state have not changed, the component skips rendering entirely. This replaces
React.memo. - Fine-grained caching — Unlike manual
useMemowhich caches at the granularity you specify, the compiler can cache individual JSX elements within a component, potentially achieving better granularity than hand-written optimization. - Rules of React enforcement — The compiler assumes your code follows the Rules of React (pure rendering, no mutation during render, stable hook call order). Code that violates these rules may produce incorrect output when compiled.
Variations
Opting out with the "use no memo" directive:
// Opt a specific component out of compiler optimization
function DebugPanel({ data }: { data: unknown }) {
"use no memo";
// This component always re-renders — useful for debugging
console.log("DebugPanel rendered at", Date.now());
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}Per-file opt-in (gradual migration):
// next.config.ts — only compile specific directories
const nextConfig = {
experimental: {
reactCompiler: {
compilationMode: "annotation",
// Or target specific paths:
// include: ["src/components/**"],
},
},
};// Opt in at the component level with "use memo"
function ExpensiveComponent({ data }: Props) {
"use memo";
// Compiler optimizes this component
return <Chart data={data} />;
}Checking compiler output:
# See what the compiler transforms
npx react-compiler-healthcheck
# Or inspect the compiled output
npx babel --plugins babel-plugin-react-compiler src/Component.tsxTypeScript Notes
- The compiler works with TypeScript out of the box. No additional type configuration is needed.
- Type annotations are preserved — the compiler only transforms runtime behavior.
- Generic components are supported. The compiler handles them the same as non-generic components.
- The
"use no memo"directive is a string literal and does not need a type declaration.
Gotchas
-
External mutable state breaks compiler assumptions — The compiler assumes pure rendering. If a component reads from a mutable global variable or a ref during render, the compiler may cache a stale result. Fix: Use
useSyncExternalStorefor external stores, or add the"use no memo"directive. -
Side effects during render — Code that logs, mutates external objects, or triggers network requests during render will be cached by the compiler, meaning side effects may not run on every render as expected. Fix: Move side effects into
useEffector event handlers. -
Non-idempotent computations — If a value computation relies on
Date.now(),Math.random(), or other non-deterministic sources, the compiler may cache a stale result. Fix: Move non-deterministic values into state or effects. -
Class components are not optimized — The compiler only works with function components and hooks. Class components are passed through unchanged. Fix: Migrate class components to function components.
-
Manual memo is still needed in rare cases — The compiler may not optimize cross-component boundaries in all cases, particularly with higher-order components or render props. Fix: Profile after enabling the compiler. Add manual
memoonly where profiling shows the compiler missed an optimization. -
Keep existing manual memoization during migration — Removing
useMemo/useCallbackbefore enabling the compiler leaves your app unoptimized. Fix: Enable the compiler first, verify performance is maintained, then remove manual memoization.
Alternatives
| Approach | Trade-off |
|---|---|
Manual useMemo + useCallback + memo | Full control; verbose; error-prone dependency arrays |
| React Compiler | Zero boilerplate; requires React 19+; experimental |
| State colocation | Avoids the need for memoization entirely; may need restructuring |
| Zustand selectors | Fine-grained subscriptions; adds external dependency |
| Million.js | Alternative compiler for virtual DOM optimization; third-party |
| Signals (Preact, Solid) | Reactive model avoids re-render problem; different framework |
FAQs
What is the React Compiler and how does it differ from manual memoization?
The React Compiler is a Babel plugin that analyzes component code at build time and automatically inserts memoization (equivalent to useMemo, useCallback, and memo). Unlike manual memoization, you do not write dependency arrays -- the compiler tracks dependencies statically.
How do you enable the React Compiler in a Next.js project?
// next.config.ts
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
export default nextConfig;Also install the Babel plugin: npm install -D babel-plugin-react-compiler.
Can you opt a specific component out of compiler optimization?
Yes. Add the "use no memo" directive at the top of the component body:
function DebugPanel({ data }: { data: unknown }) {
"use no memo";
console.log("Rendered at", Date.now());
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}What are the Rules of React that the compiler assumes you follow?
- Pure rendering: no side effects during render
- No mutation of props or state during render
- Stable hook call order (no hooks inside conditions or loops)
- Idempotent render output for the same inputs
Violating these rules may produce incorrect cached output.
Gotcha: What happens if a component reads from a mutable global variable during render?
The compiler may cache a stale result because it assumes pure rendering. If a component reads from window.someGlobal or a mutable ref during render, the cached output will not reflect updates to that global.
Fix: Use useSyncExternalStore for external stores, or add "use no memo".
Gotcha: Why might side effects during render stop firing after enabling the compiler?
The compiler caches component output. If side effects (logging, network requests, mutations) run during render, they may be skipped when the cached result is reused.
Fix: Move side effects into useEffect or event handlers.
Does the compiler work with class components?
No. The compiler only optimizes function components and hooks. Class components are passed through unchanged. Migrate class components to function components to benefit.
Should you remove existing useMemo and useCallback before enabling the compiler?
No. Enable the compiler first, verify performance is maintained, then remove manual memoization. Removing hooks before enabling the compiler leaves your app unoptimized during the gap.
Does the React Compiler require any special TypeScript configuration?
No. The compiler works with TypeScript out of the box. Type annotations are preserved -- the compiler only transforms runtime behavior. Generic components are supported without changes.
How can you check what the compiler transforms in your code?
npx react-compiler-healthcheck
# Or inspect compiled output directly:
npx babel --plugins babel-plugin-react-compiler src/Component.tsxWhat does "use memo" do in annotation compilation mode?
In compilationMode: "annotation" mode, only components with the "use memo" directive are optimized by the compiler. This allows gradual migration by opting in per component.
How does the compiler's fine-grained caching differ from manual useMemo?
Manual useMemo caches at the granularity you specify (one value per hook call). The compiler can cache individual JSX elements within a component, potentially achieving better granularity than hand-written optimization.
Related
- Memoization — Manual useMemo, useCallback, and React.memo patterns
- Preventing Re-renders — Structural patterns that reduce re-renders without memoization
- Profiling — Verifying that the compiler is effectively optimizing your components
- Bundle Optimization — Compiler adds minimal bundle overhead