Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 052 / 100

The React Compiler: a React & Next.js guide for developers

Automatic memoization — what changes for you.

Expona Admin8 min read

After five years shipping production systems across e-commerce, CRM, trading, and SaaS platforms, I've spent countless hours chasing performance bottlenecks that boiled down to one problem: developers manually wrapping components and callbacks in useMemo and useCallback, often getting it wrong or missing cases entirely. The React Compiler changes this equation—it analyzes your components at build time and inserts memoization automatically, letting you write simpler code while React handles the optimization burden. In this article, I'll walk you through what it does, what actually changes for you, and how to adopt it safely in production systems.

What It Does — Analyzes Components and Inserts Memoization at Build Time

The React Compiler is an official Meta project that runs during your build process, not at runtime. It scans your React components, understands their data dependencies, and automatically adds memoization where it matters. You don't install a new hook library or learn new syntax—you write the same JSX and component logic you always have, and the compiler optimizes it for you.

Here's the core idea: React re-renders when props or state change. Every re-render recreates objects, functions, and arrays from scratch. This isn't always bad, but when expensive computations happen downstream—or when child components rely on object identity to skip renders—unnecessary recreation becomes a real cost. Manually tracking this with useMemo and useCallback is tedious and error-prone. The compiler automates it.

Let me show you a concrete example. Before the compiler, you might write something like this to prevent a child component from re-rendering unnecessarily:

tsx
function UserProfile({ userId, onUpdate }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // Fetch user logic...
  }, [userId]);

  const handleUpdate = useCallback((newData) => {
    setLoading(true);
    // Update logic...
    onUpdate(newData);
  }, [onUpdate]); // Dependency on prop callback

  const userData = useMemo(
    () => ({ ...user, lastFetched: Date.now() }),
    [user]
  );

  return (
    <>
      <UserHeader data={userData} />
      <UserForm data={userData} onSubmit={handleUpdate} />
    </>
  );
}

With the React Compiler enabled, you can write:

tsx
function UserProfile({ userId, onUpdate }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // Fetch user logic...
  }, [userId]);

  const handleUpdate = (newData) => {
    setLoading(true);
    // Update logic...
    onUpdate(newData);
  };

  const userData = { ...user, lastFetched: Date.now() };

  return (
    <>
      <UserHeader data={userData} />
      <UserForm data={userData} onSubmit={handleUpdate} />
    </>
  );
}

The compiler analyzes this and automatically inserts memoization where it detects stable references are needed. If UserForm is memoized and relies on `onSubmit` identity, the compiler keeps `handleUpdate` stable across renders. If `userData` is passed to a memoized child, the compiler memoizes it too—without you writing a single useMemo call.

This works because the compiler understands React's rendering model. It traces which values escape the component scope (get passed as props, stored in refs, used in effects) and which are truly local. Only the escaping values get memoized.

Fewer Manual Hooks — Most useMemo and useCallback Calls Become Unnecessary

In real production codebases, I've seen teams add defensive useMemo and useCallback everywhere "just in case." This adds cognitive load, increases bundle size, and often doesn't actually solve the problem—because developers forget to include all dependencies or pass stable callbacks anyway. The compiler eliminates most of this cargo cult optimization.

Let me share a pattern I've used on multiple trading and ride-hailing platforms. When you have a data transformation pipeline, you'd normally do:

tsx
function OrderSummary({ orders, taxRate }) {
  const enrichedOrders = useMemo(
    () => orders.map(o => ({
      ...o,
      total: o.subtotal * (1 + taxRate),
      tax: o.subtotal * taxRate
    })),
    [orders, taxRate]
  );

  const sortedByTotal = useMemo(
    () => [...enrichedOrders].sort((a, b) => b.total - a.total),
    [enrichedOrders]
  );

  const summary = useMemo(
    () => ({
      count: sortedByTotal.length,
      grandTotal: sortedByTotal.reduce((sum, o) => sum + o.total, 0)
    }),
    [sortedByTotal]
  );

  return (
    <div>
      <h3>Total Orders: {summary.count}</h3>
      <h3>Grand Total: ${summary.grandTotal.toFixed(2)}</h3>
      <OrderTable orders={sortedByTotal} />
    </div>
  );
}

With the compiler, strip it down to:

tsx
function OrderSummary({ orders, taxRate }) {
  const enrichedOrders = orders.map(o => ({
    ...o,
    total: o.subtotal * (1 + taxRate),
    tax: o.subtotal * taxRate
  }));

  const sortedByTotal = [...enrichedOrders].sort((a, b) => b.total - a.total);

  const summary = {
    count: sortedByTotal.length,
    grandTotal: sortedByTotal.reduce((sum, o) => sum + o.total, 0)
  };

  return (
    <div>
      <h3>Total Orders: {summary.count}</h3>
      <h3>Grand Total: ${summary.grandTotal.toFixed(2)}</h3>
      <OrderTable orders={sortedByTotal} />
    </div>
  );
}

The compiler sees that sortedByTotal is passed to `OrderTable` as a prop. It infers that `OrderTable` likely uses it for rendering and possibly memoization, so it auto-memoizes `sortedByTotal`. It understands the transitive dependency chain: if `orders` or `taxRate` changes, the memoization boundary rebuilds, but within stable renders, the values stay the same.

This doesn't mean you delete every hook overnight. useMemo and useCallback still exist for cases where you need explicit, predictable performance control. But for the common case—passing data to child components—the manual work disappears.

Rules Still Matter — It Assumes Components Follow the Rules of React

The compiler is powerful, but it has preconditions. It assumes your code follows the Rules of React: components are pure, hooks are called unconditionally, and effects declare all dependencies. If your code violates these rules, the compiler's analysis becomes unreliable, and it may not optimize as expected—or worse, it might introduce subtle bugs.

I learned this the hard way on an e-commerce platform where a team had conditional hook calls hidden inside utility functions:

tsx
// ❌ This breaks the Rules of React
function ProductCard({ product, isFeatured }) {
  // Conditional hook call—very bad
  if (isFeatured) {
    const [viewCount, setViewCount] = useState(0);
  }

  // The compiler can't reliably analyze this
  return <div>{product.name}</div>;
}

When the compiler tries to auto-memoize `ProductCard`, it can't accurately model the state because the hook structure changes based on props. This can lead to stale closures or incorrect memoization. The fix is straightforward—always call hooks at the top level:

tsx
// ✅ Correct
function ProductCard({ product, isFeatured }) {
  const [viewCount, setViewCount] = useState(0);

  // Use isFeatured in the logic, not to control hook calls
  useEffect(() => {
    if (isFeatured) {
      trackFeaturredProductView(product.id);
    }
  }, [isFeatured, product.id]);

  return <div>{product.name}</div>;
}

Meta provides eslint-plugin-react-compiler to catch these issues. Enable it in your lint config:

tsx
{
  "plugins": ["react-compiler"],
  "rules": {
    "react-compiler/react-compiler": "error"
  }
}

Run this before enabling the compiler itself. It scans your codebase and flags violations. In my experience, most violations cluster around older code paths, conditional logic in effects, or places where developers skipped dependencies. Fix those first, and the compiler will work reliably.

Adopt Gradually — Enable Per Directory and Measure Before and After

Flipping the React Compiler on globally is tempting, but risky on large production systems. I recommend a staged rollout per directory or route, with before-and-after performance measurements.

Most build tools now support per-directory compiler config. With Babel, you'd do:

tsx
// babel.config.js
module.exports = {
  presets: [
    [
      "@babel/preset-react",
      {
        runtime: "automatic",
        development: process.env.NODE_ENV === "development"
      }
    ]
  ],
  plugins: [
    [
      "babel-plugin-react-compiler",
      {
        sources: (filename) => {
          // Only compile files in the 'dashboard' directory
          return filename.includes('/dashboard/');
        }
      }
    ]
  ]
};

With Next.js 14+, the setup is even simpler in next.config.js:

tsx
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    reactCompiler: true,
    reactCompilerOptions: {
      // Restrict to specific directories
      compilationMode: "inlineObjectParams"
    }
  }
};

module.exports = nextConfig;

Start with one feature or page. On a travel SaaS platform I worked on, we enabled it first on the checkout flow—a performance-critical path. Then we measured:

  • Time to Interactive (TTI)—Did it improve?
  • Component re-render counts—Use React DevTools Profiler to compare before and after.
  • Bundle size—The compiler adds bytecode, but memoization removal often shrinks total size.
  • Production errors—Monitor error rates for two weeks. Compiler bugs are rare but possible.

After validating the checkout flow, we rolled out to account settings, then to search and filters. Each rollout gave us confidence and exposed edge cases early. By the time we enabled it globally, we'd ironed out issues and had clear performance wins to show stakeholders.

Common Mistakes

  • Enabling globally on day one. Large codebases have lint violations and unsafe patterns. Staged rollout catches these early.
  • Not running eslint-plugin-react-compiler first. The lint rules catch most issues the compiler would struggle with. Use them as a gate.
  • Assuming the compiler is a silver bullet. It optimizes memoization, not algorithm complexity or render patterns. A component rendering thousands of items still needs virtualization.
  • Forgetting to measure. Without before-and-after metrics, you can't prove it's worth the complexity, and if a bug sneaks in, you won't catch it.
  • Mixing compiler versions. If your monorepo has different packages on different React versions, compiler behavior can be inconsistent. Keep versions aligned.

Key Takeaways

  • The React Compiler runs at build time and automatically inserts memoization, removing the need for most manual useMemo and useCallback calls.
  • It works only on code that follows the Rules of React—use eslint-plugin-react-compiler to validate before enabling.
  • Adopt gradually per directory or feature, not globally, and measure performance and stability at each stage.
  • The compiler eliminates busy work but doesn't replace architectural decisions—use it alongside profiling to catch real bottlenecks.
  • For teams already shipping memoization manually, the compiler is a low-risk way to simplify code and reduce the cognitive load of performance tuning.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles