React Compiler Tutorial: Stop Writing useMemo and useCallback

React Compiler tutorial banner showing code flowing through the compiler into an automatically memoized app
React Compiler Tutorial Banner showing your React code flowing through the React Compiler and coming out as an automatically memoized app, with data dots moving between the stages. DEVDOJO · FRONTEND React Compiler Tutorial Automatic memoization: stop writing useMemo & useCallback Your Code function List() { // no useMemo } React Compiler build time ✨ Optimized App auto memo cache fewer re-renders

Every React developer has written code like useMemo(() => ..., [a, b]) or useCallback(fn, []) and wondered, “Do I really need this here?” The React Compiler answers that question for you. It is a build-time tool that reads your components and adds memoization automatically, so your app skips unnecessary re-renders without you sprinkling useMemo, useCallback and React.memo everywhere.

In this React Compiler tutorial, you will learn what the compiler does, how to set it up in Vite, Next.js and plain Babel projects, how to check that it is working, which coding rules it expects, and how to roll it out safely in an existing project.

Quick Summary
  • React Compiler is a build-time tool (a Babel plugin) that automatically memoizes components, values and functions.
  • Version 1.0 is stable and is used in production at Meta. It works best with React 19, and also supports React 17 and 18.
  • Setup is one package (babel-plugin-react-compiler) plus one config change. In Next.js 16 it is just reactCompiler: true.
  • Your code must follow the Rules of React (pure render, no mutating props or state). The ESLint plugin eslint-plugin-react-hooks warns you when it doesn’t.
  • For new code, skip useMemo and useCallback. For old code, leave existing memoization in place unless you test carefully.

What Is the React Compiler?

The React Compiler is an optimizing compiler built by the React team at Meta. It runs while your app is being built (not in the browser), looks at each component and custom hook, and rewrites it so that values and JSX are only recalculated when their inputs actually change.

The React team released React Compiler v1.0 as stable in October 2025. Meta had already been running it in production — on the Meta Quest Store they reported up to 12% faster initial loads and page navigations, and some interactions becoming more than 2.5× faster, with no increase in memory use.

Build time, not run timeIt is a Babel plugin. Your bundler runs it once while building; users just get faster JavaScript.
No new API to learnYou write normal components. The compiler adds the caching code for you.
Works with React 17+React 19 works out of the box. React 17 and 18 need one extra setting and a small runtime package.
Safe by defaultIf the compiler is not sure a component is safe to optimize, it simply skips it.

Why Manual Memoization Is Painful

In React, when a parent component re-renders, all its children re-render too by default. Usually that is fine. But in large lists, dashboards or forms it can make the UI feel slow. Until now, the fix was manual memoization:

  • React.memo — skip re-rendering a child if its props did not change.
  • useMemo — remember the result of an expensive calculation.
  • useCallback — keep the same function reference between renders, so a memoized child doesn’t re-render.

The problem is that these tools are easy to get wrong. Forget one item in a dependency array and you get stale data. Pass a new inline object to a memoized child and all your React.memo work is wasted. Add them everywhere “just in case” and your code becomes hard to read. If you have struggled with dependency arrays before, our guide on the useEffect hook explains why they matter so much.

Re-renders without and with React Compiler Two component trees. On the left, without the compiler, a state change in App re-renders every child. On the right, with the compiler, only the component that uses the changed state re-renders. Without compiler With React Compiler App SearchBox Sidebar Input Results Menu App SearchBox Sidebar Input Results Menu re-rendersskipped (cached)
When the search query in App changes, only the parts that use it need to re-render. The compiler caches the rest for you.

How the React Compiler Works

The compiler reads your component like a very careful code reviewer. It figures out which values depend on which props and state, and then wraps each piece in a small cache. On the next render, if the inputs are the same, React reuses the cached value instead of recalculating it.

Here is a tiny component and a simplified version of what the compiler turns it into:

Greeting.jsx (your code)
export function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}
Greeting.js (compiled, simplified)
import { c as _c } from "react/compiler-runtime";

export function Greeting(t0) {
  const $ = _c(2);          // a cache with 2 slots for this component
  const { name } = t0;
  let t1;
  if ($[0] !== name) {      // did "name" change since last render?
    t1 = <h1>Hello, {name}!</h1>;
    $[0] = name;
    $[1] = t1;
  } else {
    t1 = $[1];              // reuse the cached JSX
  }
  return t1;
}

You never write or read this output yourself — it is just useful to know what is going on. Because the cached JSX is the same object as last time, React knows it can skip that part of the tree.

Smarter than manual hooks: the compiler can memoize code that comes after an early return. You cannot do that with useMemo, because hooks are not allowed after a conditional return.
function Report({ user, rows }) {
  if (!user) return <p>Please sign in.</p>;

  // The compiler caches this calculation even though it is
  // after an early return. A useMemo here would break the Rules of Hooks.
  const total = rows.reduce((sum, row) => sum + row.amount, 0);

  return <Summary total={total} rows={rows} />;
}
React Compiler build pipeline Four steps from left to right: your JSX and TypeScript source, the bundler running Babel with the React Compiler plugin, compiled JavaScript with a memo cache, and the browser rendering fewer updates. 1. Source.jsx / .tsx files 2. BundlerVite / Next.js + compiler 3. OutputJS with memo cache 4. Browserfewer re-renders All the work happens in steps 2–3, before your code reaches users.
The React Compiler runs inside your build step. Users only download the already-optimized JavaScript.

How to Set Up React Compiler

The setup depends on your tool. Pick your tab below. All three use the same package, babel-plugin-react-compiler, from the official installation guide.

Vite with @vitejs/plugin-react 6 or newer (Vite 8). Version 6 of the React plugin no longer runs Babel itself, so you add the Rolldown Babel plugin and use the compiler preset:

npm install -D babel-plugin-react-compiler @rolldown/plugin-babel
vite.config.js
import { defineConfig } from 'vite';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import babel from '@rolldown/plugin-babel';

export default defineConfig({
  plugins: [
    react(),
    babel({
      presets: [reactCompilerPreset()],
    }),
  ],
});

Older @vitejs/plugin-react (5.x and below): pass the plugin through the built-in babel option instead.

// vite.config.js (plugin-react 5.x or older)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: ['babel-plugin-react-compiler'],
      },
    }),
  ],
});

Next.js 16 and newer. The reactCompiler option is stable and lives at the top level of your config. Next.js also uses a fast SWC check so the compiler only runs on files that contain JSX or hooks.

npm install -D babel-plugin-react-compiler
next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  reactCompiler: true,
};

export default nextConfig;

On Next.js 15 the same option lived under experimental.reactCompiler. If you are also using caching in the App Router, see our guide to Next.js use cache — the two features work well together.

Any Babel-based setup (custom webpack, Metro for React Native, etc.). The compiler must run first in the plugin list:

npm install -D babel-plugin-react-compiler
babel.config.js
module.exports = {
  plugins: [
    'babel-plugin-react-compiler', // must run first!
    // ...your other plugins
  ],
};

Expo: new apps created with npx create-expo-app@latest (SDK 54 and up) already have the compiler turned on.

Starting a new project? npm create vite@latest and npx create-next-app@latest both let you pick a template with the compiler already enabled.

Using React 17 or 18

The compiler is designed for React 19, where the cache helper lives inside react/compiler-runtime. On React 17 or 18, install the small runtime package as a normal dependency (not a dev dependency) and tell the compiler which version you use:

npm install react-compiler-runtime@latest
babel.config.js
const ReactCompilerConfig = {
  target: '18', // use '17' for React 17 — a string, major version only
};

module.exports = {
  plugins: [
    ['babel-plugin-react-compiler', ReactCompilerConfig],
  ],
};

Before and After: A Real Example

Let’s look at a typical product list with a search box and an “Add to cart” button. This is how many of us wrote it before the compiler:

ProductList.jsx — before (manual memoization)
import { memo, useCallback, useMemo, useState } from 'react';

const ProductRow = memo(function ProductRow({ product, onAdd }) {
  return (
    <li>
      {product.name} — ₹{product.price}
      <button onClick={() => onAdd(product.id)}>Add</button>
    </li>
  );
});

export default function ProductList({ products }) {
  const [query, setQuery] = useState('');
  const [cart, setCart] = useState([]);

  const visible = useMemo(
    () =>
      products.filter((p) =>
        p.name.toLowerCase().includes(query.toLowerCase())
      ),
    [products, query]
  );

  const handleAdd = useCallback((id) => {
    setCart((current) => [...current, id]);
  }, []);

  return (
    <section>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search products"
      />
      <p>Items in cart: {cart.length}</p>
      <ul>
        {visible.map((p) => (
          <ProductRow key={p.id} product={p} onAdd={handleAdd} />
        ))}
      </ul>
    </section>
  );
}

With the React Compiler turned on, you can write the same thing as plain, readable React:

ProductList.jsx — after (compiler handles it)
import { useState } from 'react';

function ProductRow({ product, onAdd }) {
  return (
    <li>
      {product.name} — ₹{product.price}
      <button onClick={() => onAdd(product.id)}>Add</button>
    </li>
  );
}

export default function ProductList({ products }) {
  const [query, setQuery] = useState('');
  const [cart, setCart] = useState([]);

  const visible = products.filter((p) =>
    p.name.toLowerCase().includes(query.toLowerCase())
  );

  function handleAdd(id) {
    setCart((current) => [...current, id]);
  }

  return (
    <section>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search products"
      />
      <p>Items in cart: {cart.length}</p>
      <ul>
        {visible.map((p) => (
          <ProductRow key={p.id} product={p} onAdd={handleAdd} />
        ))}
      </ul>
    </section>
  );
}

The compiler caches visible (recalculated only when products or query change), keeps handleAdd stable, and memoizes each ProductRow‘s JSX. When you click “Add”, the cart count updates but the product rows are reused from the cache.

Try it yourself

Add a quick log to see the difference in your own project:

function ProductRow({ product, onAdd }) {
  console.log('render row', product.id); // remove after testing
  // ...
}

Without the compiler, every click on “Add” logs every row. With the compiler, typing in the search box only logs rows whose data actually changed, and clicking “Add” logs none.

Note about Strict Mode: in development, React’s Strict Mode renders components twice on purpose. Don’t let the double logs confuse you — compare counts, or test in a production build.

How to Check It Is Working

  1. Install the React Developer Tools browser extension.
  2. Run your app in development mode (npm run dev).
  3. Open the Components tab. Components optimized by the compiler show a “Memo ✨” badge next to their name.

You can also look at the built code. Compiled components import c from react/compiler-runtime (or from react-compiler-runtime on React 17/18) and use a $ cache array, like in the example above.

If a component has no badge, the compiler decided to skip it — usually because the code breaks one of the rules in the next section. The ESLint plugin will tell you why.

Rules Your Code Must Follow

The compiler assumes your components follow the Rules of React. In simple words: rendering must be a pure calculation. Given the same props and state, a component should return the same JSX and should not change anything outside itself. Here are the four mistakes that most often stop the compiler.

1. Mutating props or state

// ❌ Bad: .sort() changes the original array that came from props
function TopScores({ scores }) {
  const sorted = scores.sort((a, b) => b - a);
  return <List items={sorted} />;
}

// ✅ Good: make a sorted copy instead
function TopScores({ scores }) {
  const sorted = scores.toSorted((a, b) => b - a); // or [...scores].sort(...)
  return <List items={sorted} />;
}

2. Reading or writing refs during render

// ❌ Bad: ref.current is read while rendering
function Timer() {
  const startRef = useRef(Date.now());
  return <p>Started at {startRef.current}</p>;
}

// ✅ Good: store values you show on screen in state
function Timer() {
  const [startedAt] = useState(() => Date.now());
  return <p>Started at {startedAt}</p>;
}

Reading ref.current inside event handlers and effects is completely fine — just not in the render body.

3. Impure values during render

// ❌ Bad: a new random id on every render
function Badge() {
  const id = Math.random();
  return <span id={id}>New</span>;
}

// ✅ Good: use the built-in hook for stable ids
import { useId } from 'react';

function Badge() {
  const id = useId();
  return <span id={id}>New</span>;
}

4. Calling setState during render

// ❌ Bad: setting state while rendering causes extra renders or loops
function Cart({ items }) {
  const [count, setCount] = useState(0);
  setCount(items.length);
  return <p>{count} items</p>;
}

// ✅ Good: derive the value directly — no state needed
function Cart({ items }) {
  const count = items.length;
  return <p>{count} items</p>;
}
Good news: if the compiler finds code like this, it does not break your app. It just skips optimizing that one component or hook and keeps going with the rest.

Catch Problems Early with ESLint

The compiler’s checks are built into the latest eslint-plugin-react-hooks. Its recommended preset now includes rules such as purity, refs, immutability, set-state-in-render, set-state-in-effect and incompatible-library, alongside the classic rules-of-hooks and exhaustive-deps.

npm install -D eslint-plugin-react-hooks@latest
eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig } from 'eslint/config';

export default defineConfig([
  reactHooks.configs.flat.recommended,
]);

Tip: turn on the lint rules before you turn on the compiler. Fixing the warnings first makes the switch much smoother — and the rules are useful even if you never enable the compiler.

Adopting It in an Existing App

For a brand-new project, just enable the compiler for everything. For a big existing codebase, go step by step.

Option A: opt-in mode with “use memo”

Set compilationMode to 'annotation'. Now the compiler only touches components and hooks that start with the "use memo" directive.

next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  reactCompiler: {
    compilationMode: 'annotation',
  },
};

export default nextConfig;
export default function Dashboard() {
  'use memo'; // only this component is compiled
  // ...
}

With Babel, pass the same option: ['babel-plugin-react-compiler', { compilationMode: 'annotation' }].

Option B: compile everything, opt out problem files

If one component misbehaves after enabling the compiler, add "use no memo" at the top of it. Treat this as a temporary bandage while you fix the underlying issue.

function LegacyChart({ data }) {
  'use no memo'; // skip this component for now
  // ...
}

Should you delete your existing useMemo and useCallback?

The React team’s advice is clear:

  • New code: rely on the compiler. Use useMemo/useCallback only when you need exact control — for example, a value used as an effect dependency that must not change.
  • Existing code: leave current memoization in place, or remove it only with careful testing. Removing it can change what the compiler outputs.

There is no rush. Keeping old hooks around does no harm; the compiler works with them.

Common Errors and Fixes

ProblemLikely causeFix
No “Memo ✨” badge in DevToolsCompiler not running, or it skipped the componentCheck your config; run ESLint to see which rule the component breaks
Vite: the babel option in react() does nothing@vitejs/plugin-react 6 removed the inline Babel optionUse @rolldown/plugin-babel with reactCompilerPreset()
Cannot find module 'react/compiler-runtime'Project uses React 17 or 18Install react-compiler-runtime and set target: '18' (or '17')
Next.js 16 ignores or warns about experimental.reactCompilerIn Next.js 16 the option moved out of experimentalUse top-level reactCompiler: true
UI does not update after you change an array or objectCode mutates state or props directlyCreate new arrays/objects ([...arr], {...obj}, toSorted())
A third-party hook returns stale dataLibrary returns mutable objects the compiler can’t trackWatch for the incompatible-library lint warning; add "use no memo" to that component
Effect runs more or less often than beforeEffect depended on values that are now memoized differentlyKeep an explicit useMemo/useCallback for effect dependencies that must stay stable

Best Practices

  • Lint first, compile second. Turn on eslint-plugin-react-hooks and fix warnings before switching the compiler on.
  • Measure, don’t guess. Use the React DevTools Profiler before and after enabling the compiler so you can see the real gain.
  • Keep render pure. No API calls, no random values, no mutations in the component body. Put side effects in event handlers or effects.
  • Prefer derived values over extra state. If something can be calculated from props or state, calculate it — the compiler will cache it.
  • Roll out gradually in big apps. Start with annotation mode or a single folder, ship it, watch for bugs, then expand.
  • Use “use no memo” as a short-term fix only. Leave a TODO and come back to fix the real problem.
  • Have good tests. Memoization changes when code runs. End-to-end and component tests give you confidence during the switch.
  • Typing matters too. If you are upgrading tooling at the same time, our TypeScript 7 migration guide covers the compiler side of TypeScript.

FAQ

Is React Compiler ready for production?

Yes. Version 1.0 was released as stable in October 2025, and Meta uses it in production apps such as the Meta Quest Store.

Do I still need useMemo and useCallback?

Mostly no for new code. Keep them for special cases where you need exact control, like a value used as a dependency of useEffect. Existing ones can stay in place.

Does React Compiler work with React 18?

Yes. Set target: '18' in the compiler options and install react-compiler-runtime as a regular dependency. React 17 is supported the same way with target: '17'.

Will it make my build slower?

A little, because Babel has to run on your components. In Next.js the impact is small because it only compiles files that contain JSX or hooks.

Does it work with TypeScript?

Yes. The compiler works on .tsx and .ts files the same way it works on JavaScript.

What happens if my code breaks the Rules of React?

The compiler skips optimizing that component or hook and compiles the rest. Your app still works; that component just doesn’t get the speed boost.

Can I use it with React Native?

Yes. It works through Babel, and new Expo apps (SDK 54 and up) have it enabled by default.

Conclusion

The React Compiler is one of the biggest quality-of-life upgrades React has had in years. Instead of guessing where to add useMemo, useCallback and React.memo, you write simple, readable components and let the build step handle performance. Turn on the ESLint rules, enable the compiler with one config change, check for the “Memo ✨” badge, and roll it out step by step in older apps.

Try it today on one page of your project — you will likely delete more code than you add.

New posts every day on DevDojo

Practical tutorials on React, Next.js, Node.js, Python, AI and DevOps — written in simple English for developers in India and around the world. Bookmark devdojo.co.in and drop your questions in the comments below!

Share