
Mykola Nesterchuk - March 16, 2026
For years, writing performant React meant wrapping things in useMemo, stabilizing callbacks with useCallback, and reaching for React.memo whenever a list item felt slow. The mental overhead was real - dependency arrays, stale closures, and the constant question: "did I memoize this correctly?"
React Compiler v1.0, released in October 2025, changes that calculus. It is a build-time tool that analyzes your components and inserts memoization automatically. In most cases, you write plain code and the compiler handles the rest.
This article covers what the compiler actually does under the hood, how to enable it, when manual memoization still makes sense, and a practical migration path for existing codebases.
The React Compiler is a Babel/SWC plugin that transforms your source code before it reaches the browser. It reads each component, builds a dependency graph of every value and function, and generates optimized output - similar to what you would write manually with useMemo and useCallback, but more precise.
Take a simple example:
// What you write function Dashboard({ data }) { const summary = computeExpensiveSummary(data); return <SummaryCard data={summary} />; }
The compiler transforms this roughly into:
// What the compiler generates function Dashboard({ data }) { const summary = useMemo(() => computeExpensiveSummary(data), [data]); return <SummaryCard data={summary} />; }
But it goes further than what most developers write manually. Functions passed as props get stable references automatically. Child components are treated as effectively memoized when their inputs do not change. The compiler applies memoization at the granularity of individual expressions, not just at the hook call level.
The key requirement: your components must be pure. The compiler assumes that render functions have no side effects and that the same inputs always produce the same output. If your component mutates external state during render, the compiler either skips it or produces incorrect output.
For new projects using Next.js (where the compiler ships as the default from Next.js 15+), it is already on. For existing projects with Vite or a custom build setup:
npm install -D babel-plugin-react-compiler eslint-plugin-react-compiler
For Vite:
// vite.config.ts import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [ react({ babel: { plugins: ['babel-plugin-react-compiler'], }, }), ], });
For Next.js (if not already enabled):
// next.config.ts const nextConfig = { experimental: { reactCompiler: true, }, }; export default nextConfig;
Once enabled, open React DevTools. Components optimized by the compiler show a small ✨ badge. If a component is not optimized, the compiler skipped it - usually because it detected a purity violation.
The ESLint plugin flags code that the compiler cannot safely optimize:
npm install -D eslint-plugin-react-compiler
// .eslintrc { "plugins": ["react-compiler"], "rules": { "react-compiler/react-compiler": "error" } }
Treat compiler lint errors as blockers, not warnings. They surface exactly the patterns that prevent optimization.
The compiler handles the vast majority of cases, but not all of them. There are three categories where manual memoization still makes sense.
Some libraries expose functions whose reference stays the same but whose return value changes internally. react-hook-form's watch function is a known example:
// The compiler cannot detect that watch() has interior mutability const { watch } = useForm(); // You need to memoize explicitly here const values = useMemo(() => watch(), [watch]);
Drag-and-drop libraries like react-dnd also depend on reference equality for registering handlers. Removing memoization there causes handlers to re-register on every render.
Chart libraries, map libraries, and some animation libraries accept callbacks or config objects and compare them by reference to decide whether to re-render. If you pass an unstabilized function to these, the library will re-render on every React render regardless of what the compiler does:
// Chart.js and similar libs compare this by identity const chartOptions = useMemo(() => ({ scales: { y: { beginAtZero: true } }, onClick: handleBarClick, }), [handleBarClick]);
For calculations that are measurably slow - filtering tens of thousands of items, running complex transforms - keeping an explicit useMemo documents intent and gives you precise control over when recomputation happens:
// Intentional - expensive and worth documenting const sortedAndFilteredRows = useMemo(() => rows .filter(row => matchesAllFilters(row, activeFilters)) .sort(comparator), [rows, activeFilters, comparator] );
The rule of thumb: if you are adding useMemo defensively - "just in case" - remove it and let the compiler handle it. If you are adding it because you measured a real cost or because a third-party library requires it, keep it.
If the compiler optimizes a component incorrectly - which can happen with complex state patterns it cannot statically analyze - you can opt out:
function ComplexComponent({ data }) { 'use no memo'; // compiler skips this component entirely // ... }
Use this sparingly. It exists for edge cases, not as a general escape from fixing purity violations.
Do not try to migrate everything at once. The compiler is additive - it works alongside existing memoization and skips components it cannot optimize safely.
A practical order:
Enable the ESLint plugin first - before touching any code. Fix the violations it flags. Each violation is a purity issue or a rules-of-hooks issue that would prevent the compiler from running.
Enable the compiler with no other changes. Check DevTools to see what gets the ✨ badge.
Remove defensive memoization - start with useMemo calls that wrap cheap operations, and useCallback calls that only exist to satisfy React.memo on a child. The compiler covers these automatically.
Profile before and after - use the React DevTools Profiler to confirm that the components you changed are not re-rendering more than before.
Keep targeted memoization - for the third-party library cases and expensive computations described above.
// Before migration - defensive boilerplate everywhere function UserList({ users, onSelect }) { const sorted = useMemo( () => [...users].sort((a, b) => a.name.localeCompare(b.name)), [users] ); const handleSelect = useCallback( (id: string) => onSelect(id), [onSelect] ); return ( <ul> {sorted.map(user => ( <UserItem key={user.id} user={user} onSelect={handleSelect} /> ))} </ul> ); } // After migration - the compiler handles stability function UserList({ users, onSelect }) { const sorted = [...users].sort((a, b) => a.name.localeCompare(b.name)); return ( <ul> {sorted.map(user => ( <UserItem key={user.id} user={user} onSelect={(id) => onSelect(id)} /> ))} </ul> ); }
The second version is both shorter and at least as performant - the compiler generates stable references for sorted and the inline arrow function automatically.
The compiler shifts the mental model: instead of writing components and then layering memoization on top, you write components that are pure and let performance follow from that.
In practice this means:
console.log, mutations, and network calls into useEffect or event handlers.Most of these are already part of the Rules of React. The compiler just enforces them more strictly and rewards compliance with automatic optimization.
Understanding the compiler is easier if you already know how React's render pipeline works. When a component re-renders, React calls the component function and reconciles the output against the previous virtual DOM. The compiler does not change this model - it makes the inputs to that process more stable, so reconciliation finds fewer differences and skips more work.
The commit phase, useEffect timing, and the Server/Client boundary in Next.js App Router are all unaffected. The compiler is purely a render-phase optimization.
React Compiler v1.0 automates most of what developers used to do manually with useMemo, useCallback, and React.memo.
The goal is not to delete every useMemo in your codebase. It is to stop writing them by default and only reach for them when you have a specific reason.