React Compiler + Vite: Drop useMemo and useCallback — ContentBuffer guide

React Compiler + Vite: Drop useMemo and useCallback

K
Kodetra Technologies··7 min read Intermediate

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/memo calls you can safely delete.
  • How to enable the eslint-plugin-react-compiler rules 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.

  1. 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.
  2. Build output check. Run vite build and search the bundle for useMemoCache or c[ (the compiler's internal cache slot reads). Their presence proves the transform ran.
  3. The healthcheck script. 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.current during 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 stable fn in the dep array. The compiler stabilizes fn for you, but you must include it as a dep.
  • Shipping compilationMode: 'all' on day one. Roll out per-directory using the sources filter, fix violations, then expand. Big-bang migrations bury real bugs in the noise.

Quick reference

Old patternAfter React CompilerNotes
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 ComponentParent skips re-render automatically.
'use memo' directiveOpt-in onlyUsed in compilationMode: 'opt-in'.
'use no memo' directivePer-file escape hatchDisable the compiler for one file while you debug.

A safe migration order

  1. Install the lint plugin and fix every violation. Ship that PR alone.
  2. Add the Vite plugin in opt-in mode and turn on a single low-risk file with 'use memo'. Watch production for a week.
  3. Switch to compilationMode: 'all' behind a sources filter that targets one directory.
  4. Expand the filter directory by directory, removing manual useMemo/useCallback/memo as you go.
  5. 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-healthcheck into CI so a regression in one component does not silently disable optimization.
  • Pair the compiler with React 19's useEffectEvent to 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

Subscribe to join the conversation...

Be the first to comment