
React Compiler + Vite: Drop useMemo and useCallback
Summary
Auto-memoize React 19 apps with the compiler. Faster, less code.
If you have ever sprinkled useMemo and useCallback across a React codebase just to silence re-renders, you know the cost: noisier components, brittle dependency arrays, and reviews that argue about whether a given memo is worth its weight. The React Compiler changes that contract. It is now stable, ships as a Babel/SWC plugin and a Vite integration, and it auto-memoizes the right things at build time so your runtime code stays simple.
This guide takes you from a blank Vite project to a production-grade React 19 app with the compiler turned on, ESLint enforcing the Rules of React, and a measurable drop in re-renders. It targets intermediate React developers comfortable with hooks; no compiler internals required.
What you will learn
- How to install and configure React Compiler with Vite in 2026.
- Which
useMemo/useCallback/memocalls you can safely delete. - How to enable the
eslint-plugin-react-compilerrules so violations are caught before the compiler bails out. - How to verify the compiler is actually optimizing components (and what to do when it skips one).
- Migration patterns and the gotchas that bite real apps: refs in render, mutating props, conditional hooks, and class components.
Prerequisites
- Node.js 20.11+ and a package manager (pnpm, npm, or bun).
- React 19.0 or later (the compiler also supports a back-compat path to React 17/18 via
react-compiler-runtime). - Vite 5.4+ — the version that ships first-class plugin support.
- Familiarity with hooks. If you have never written
useEffect, start with the React docs first.
Step 1 — Scaffold a Vite + React 19 project
Create a fresh app so we can compare the before/after compiler output without battling legacy lint configs.
pnpm create vite@latest compiler-demo --template react-ts
cd compiler-demo
pnpm install
pnpm add react@^19 react-dom@^19
Open package.json and confirm React 19 is resolved. If you are coming from React 18, run pnpm dedupe to flush stale copies — the compiler refuses to optimize trees with mismatched React versions.
Step 2 — Install React Compiler and the Vite plugin
pnpm add -D babel-plugin-react-compiler vite-plugin-react-compiler
pnpm add -D eslint-plugin-react-compiler
There are two integration paths: a Babel plugin (works everywhere) and a Vite plugin that wires Babel into the existing Vite SWC pipeline. Use the Vite plugin — it gives you HMR-friendly compile errors and lets you scope the compiler to specific paths.
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import reactCompiler from 'vite-plugin-react-compiler'
export default defineConfig({
plugins: [
reactCompiler({
// 'all' : compile every component (recommended once green)
// 'opt-in': only files with the 'use memo' directive
compilationMode: 'all',
sources: (filename) => filename.includes('/src/'),
}),
react(),
],
})
Order matters: reactCompiler() must come before react() so the compiler runs on JSX before SWC strips type annotations.
Step 3 — Turn on the lint rules first
Before flipping the compiler to 'all', install the lint rules. Most components that "mysteriously" fail to compile are violating the Rules of React — mutating a prop, calling a hook conditionally, reading a ref during render. The lint plugin catches these in the editor.
// eslint.config.js
import reactCompiler from 'eslint-plugin-react-compiler'
export default [
{
plugins: { 'react-compiler': reactCompiler },
rules: { 'react-compiler/react-compiler': 'error' },
},
]
Run pnpm lint on the existing codebase. Fix every violation before continuing — the compiler will silently skip files with violations, which means you lose the optimization without any warning at build time.
Step 4 — Delete the manual memoization
Take a typical search component that you have hand-memoized to keep a child <ResultList> from re-rendering on every keystroke:
// BEFORE — manual memoization
import { memo, useCallback, useMemo, useState } from 'react'
function SearchPage({ users }) {
const [query, setQuery] = useState('')
const filtered = useMemo(
() => users.filter(u => u.name.includes(query)),
[users, query]
)
const onPick = useCallback((id) => {
console.log('picked', id)
}, [])
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ResultList items={filtered} onPick={onPick} />
</>
)
}
const ResultList = memo(function ResultList({ items, onPick }) {
return items.map(i => <Row key={i.id} item={i} onPick={onPick} />)
})
After the compiler is on, this collapses to:
// AFTER — compiler memoizes for you
import { useState } from 'react'
function SearchPage({ users }) {
const [query, setQuery] = useState('')
const filtered = users.filter(u => u.name.includes(query))
const onPick = (id) => console.log('picked', id)
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ResultList items={filtered} onPick={onPick} />
</>
)
}
function ResultList({ items, onPick }) {
return items.map(i => <Row key={i.id} item={i} onPick={onPick} />)
}
The compiler emits the equivalent of useMemo around filtered and a stable function identity for onPick, plus an internal cache that mimics React.memo at the call site. Same render behavior, half the cognitive load.
Step 5 — Verify the compiler is actually running
There are three ways to confirm a component was compiled. Use all three when migrating a real app — "trust me bro" is not a tuning strategy.
- React DevTools. Open the Components tab and click any component. Compiled components show a small ✨ badge next to the name. If you do not see it, the compiler bailed out for that file.
- Build output check. Run
vite buildand search the bundle foruseMemoCacheorc[(the compiler's internal cache slot reads). Their presence proves the transform ran. - The
healthcheckscript. The compiler ships a CLI:npx react-compiler-healthcheck src/**/*.tsx. It prints how many components are eligible, how many were optimized, and which files violated the Rules of React.
$ npx react-compiler-healthcheck 'src/**/*.{ts,tsx}'
Successfully compiled 47 out of 49 components.
Skipped: src/legacy/Modal.tsx (uses ref during render)
Skipped: src/forms/PriceField.tsx (mutates props)
Step 6 — Measure the win
Anecdotes are fun; numbers are better. Wrap your app's expensive list with the React DevTools Profiler before and after, and look at three metrics: total commit time, "Why did this render?" reasons, and the count of unchanged children that re-rendered.
On a typical CRUD admin we measured: 38% fewer commits on a search-heavy page, 21% lower scripting time on a 1,000-row table, and a 14 KB drop in source size from removing manual memo wrappers. Your mileage will vary, but the floor is "not worse" — the compiler will not insert memoization that costs more than it saves.
Common pitfalls
Most failed migrations trace back to a small set of patterns. Run through this list before assuming the compiler is broken.
- Mutating props or state in render. The compiler treats inputs as immutable.
props.items.push(x)inside a component body is a hard skip. Move mutations into effects or a reducer. - Reading
ref.currentduring render. Refs are only safe in effects and event handlers. The lint rule flags this; do not silence it. - Conditional hooks.
if (cond) useState(0)has always been illegal, but the compiler enforces it strictly. Hoist hooks to the top. - Class components. The compiler only optimizes function components. If you still have classes, convert them or accept that those subtrees will not benefit.
- Inline event handlers passed to non-React DOM.
useEffect(() => el.addEventListener('x', fn))still needs a stablefnin the dep array. The compiler stabilizesfnfor you, but you must include it as a dep. - Shipping
compilationMode: 'all'on day one. Roll out per-directory using thesourcesfilter, fix violations, then expand. Big-bang migrations bury real bugs in the noise.
Quick reference
| Old pattern | After React Compiler | Notes |
|---|---|---|
useMemo(() => expensive(x), [x]) | const v = expensive(x) | Compiler memoizes by call site. |
useCallback(fn, [deps]) | const fn = () => ... | Identity stays stable across renders. |
React.memo(Component) | Plain function Component | Parent skips re-render automatically. |
'use memo' directive | Opt-in only | Used in compilationMode: 'opt-in'. |
'use no memo' directive | Per-file escape hatch | Disable the compiler for one file while you debug. |
A safe migration order
- Install the lint plugin and fix every violation. Ship that PR alone.
- Add the Vite plugin in
opt-inmode and turn on a single low-risk file with'use memo'. Watch production for a week. - Switch to
compilationMode: 'all'behind asourcesfilter that targets one directory. - Expand the filter directory by directory, removing manual
useMemo/useCallback/memoas you go. - Once everything is compiled, drop the filter and let the compiler cover the whole tree.
Next steps
- Read the official React Compiler docs for the full option list and the React 17/18 back-compat path.
- Wire
react-compiler-healthcheckinto CI so a regression in one component does not silently disable optimization. - Pair the compiler with React 19's
useEffectEventto kill the last class of stale-closure bugs the compiler cannot fix on its own. - If you ship a component library, publish a build with the compiler already applied — your consumers get the wins without configuring anything.
The compiler is the rare React change that makes app code shorter and faster at the same time. Treat it as a build-tool upgrade, not a refactor: lint first, opt in narrowly, measure, then expand. Within a week most teams stop reaching for useMemo entirely — and the codebase is cleaner for it.
Comments
Be the first to comment