DEV Community

Cover image for React 20 Compiler: Which Hooks It Eliminates, Which It Cannot, and How to Audit Your Codebase Before Upgrading
jsmanifest
jsmanifest

Posted on Originally published at jsmanifest.com

React 20 Compiler: Which Hooks It Eliminates, Which It Cannot, and How to Audit Your Codebase Before Upgrading

React 20 Compiler: Which Hooks It Eliminates, Which It Cannot, and How to Audit Your Codebase Before Upgrading

This article was written with the assistance of AI, under human supervision and review.

The React Compiler shipped with React 19 as a stable Babel plugin that automatically memoizes components and hooks, eliminating the majority of manual useMemo, useCallback, and React.memo calls. Teams installing version 1.0.0 today find that most performance-oriented memoization becomes redundant. The compiler analyzes data flow, understands mutability, and applies granular memoization that survives conditional returns and early exits where manual hooks fail.

The problem surfaces when developers treat the compiler as a drop-in replacement without auditing the semantic contracts their existing hooks enforce. A useCallback wrapping an event handler for performance looks identical in the source to one ensuring exact reference identity for a useEffect dependency array. The compiler sees both as memoization candidates, removes them, and breaks the second use case. The codebase compiles, tests pass in isolation, and production deploys a useEffect that fires on every render because the dependency lost its stable identity.

The solution requires a three-phase audit before enabling the compiler: running the ESLint plugin to surface Rules of React violations, checking library compatibility for interior mutability patterns, and marking effect-dependency memoization with explicit comments or redesigning the dependency contracts. Developers who complete this audit can enable the compiler with confidence, knowing which hooks to keep and which the compiler handles transparently.

Key Takeaways

  • The React Compiler automatically memoizes components and hooks, making most useMemo, useCallback, and React.memo calls redundant for performance-only use cases.
  • Effect dependencies relying on exact identity require explicit useMemo or a redesigned dependency contract before compiler migration.
  • Libraries with interior mutability (React Hook Form, TanStack Table, MobX) need per-component "use no memo" directives or wrapper patterns.
  • The ESLint plugin in eslint-plugin-react-hooks@latest identifies Rules of React violations without requiring compiler installation, serving as the first migration audit step.
  • Pin the compiler to an exact version (--save-exact) if test coverage is incomplete to prevent unexpected memoization changes across minor releases.

Which Hooks the React Compiler Eliminates (and the Patterns It Auto-Memoizes)

The compiler targets memoization hooks installed solely for performance optimization. Any useMemo or useCallback wrapping an expensive computation, JSX generation, or event handler receives automatic granular memoization that the compiler derives from its static analysis. The official documentation confirms the compiler handles memoization automatically, including cases where manual hooks cannot be placed, such as after an early return.

A component that filters a large dataset and renders a virtualized list no longer needs explicit useMemo around the filter operation. The compiler detects the data dependency, memoizes the filtered result, and re-computes only when the source array or filter predicate changes. The same applies to event handlers wrapped in useCallback to prevent child re-renders. The compiler memoizes the handler automatically and propagates reference stability where it matters.

The compiler also memoizes conditionally, a pattern impossible with manual hooks. A component that returns null early based on a prop check can still have its post-return JSX memoized because the compiler understands the control flow and applies memoization to the reachable paths.

export default function ProductCard({ product }) {
  if (!product) {
    return null;
  }

  // Compiler memoizes this computation after the early return
  const discountedPrice = product.price * (1 - product.discount);

  return (
    <div className="card">
      <h3>{product.name}</h3>
      <p className="price">${discountedPrice.toFixed(2)}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The React 19 release announcement states the compiler supports optional chains and array indices as dependencies, improving the precision of automatic memoization and reducing over-memoization in components that previously required manual hooks to avoid unnecessary re-renders.

React Compiler automatic memoization visualization

React Compiler automatic memoization visualization

Which Hooks and Patterns the Compiler Cannot Handle

The compiler cannot remove hooks that enforce semantic contracts beyond performance. Effect dependencies requiring exact identity guarantees, libraries with interior mutability, and escape hatches for explicit control over memoization granularity remain manual responsibilities.

Effect dependencies represent the most common compiler limitation. A useMemo wrapping a configuration object passed to a useEffect dependency array ensures the effect fires only when the configuration semantically changes, not when the parent component re-renders. The compiler sees the object creation, memoizes it for performance, but does not understand the identity contract the effect requires.

function DataFetcher({ userId, filters }) {
  // This useMemo must stay: the effect needs exact identity
  const queryConfig = useMemo(
    () => ({ userId, filters, timestamp: Date.now() }),
    [userId, filters]
  );

  useEffect(() => {
    fetch(`/api/data?${new URLSearchParams(queryConfig)}`);
  }, [queryConfig]); // Effect fires only when userId/filters change
}
Enter fullscreen mode Exit fullscreen mode

Libraries with interior mutability break the compiler's assumption that props and state are immutable. React Hook Form's watch() method, TanStack Table's row model, and MobX observables all mutate internal state without triggering React's normal render cycle. The compiler cannot track these mutations and may apply memoization that prevents the component from seeing updates.

Third-party hooks that violate the Rules of React also bypass compiler optimization. A custom hook calling another hook conditionally or in a loop receives a compilation error or silent skipping, leaving the component unmemoized.

Pattern Compiler behavior Developer action
Effect dependency identity Memoizes for performance, not identity Keep explicit useMemo or redesign dependency
Interior mutability (RHF, TanStack, MobX) May prevent component updates Add "use no memo" directive or wrapper
Conditional hook calls Skips compilation with error Fix Rules of React violation before enabling
Expensive computation with no identity contract Auto-memoizes Remove manual useMemo

Pre-Migration Audit: Running the ESLint Plugin and Identifying Manual Optimization Debt

The first migration step runs the ESLint plugin from eslint-plugin-react-hooks@latest to surface Rules of React violations before the compiler touches production code. The React Compiler documentation recommends installing the plugin standalone, without the compiler, because it identifies breaking patterns that will cause silent compilation failures.

The plugin flags setState patterns that cause render loops, expensive work inside effects, and unsafe ref access during render. These violations break the compiler's data-flow assumptions and must be fixed before enabling automatic memoization.

npm install --save-dev eslint-plugin-react-hooks@latest
Enter fullscreen mode Exit fullscreen mode
// eslint.config.js (Flat Config)
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig } from 'eslint/config';

export default defineConfig([
  reactHooks.configs.flat.recommended,
]);
Enter fullscreen mode Exit fullscreen mode

The second audit step catalogs every useMemo and useCallback in the codebase and classifies each by intent. Performance-only hooks receive a // Performance: compiler handles comment and a ticket to remove them in the next cleanup sprint. Identity-contract hooks receive a // Identity contract: keep for effect dependency comment and remain in the codebase permanently.

A spreadsheet tracking hook location, intent, and removal status provides visibility into the migration. Teams with thousands of memoization hooks prioritize high-traffic components first and leave low-risk utilities for later sprints.

Library Compatibility Check: React Hook Form, TanStack, MobX, and Interior Mutability

Libraries relying on interior mutability require component-level compiler opt-outs or wrapper patterns that isolate the mutability from compiled code. The compiler's auto-memoization assumes props and state are immutable and that re-renders propagate through standard React update mechanisms. Libraries violating these assumptions need explicit handling.

React Hook Form's watch() method mutates internal subscription state without triggering React updates. A component calling watch('fieldName') directly in the render body and relying on the compiler for memoization will not see field value changes because the compiler's memoization cache does not invalidate when the form's internal state changes.

The community discussion on React Hook Form compatibility identifies three patterns that survive the compiler: passing primitives to child components instead of the entire form object, having children call useFormState directly, and binding fields with <Controller> or useController whenever the value is rewritten programmatically through reset() or useForm({ values }). Plain register does not survive React Hook Form's internal _reset under the compiler because the reset mechanism bypasses the compiler's dependency tracking.

TanStack Table and TanStack Virtual present similar challenges. The row model from useReactTable() is a mutable object that updates in place. Components extracting rows and mapping them to JSX need a "use no memo" directive at the component level or a wrapper component that calls the hook and passes primitives down.

'use no memo';

export function DataTable({ columns, data }) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    <table>
      <tbody>
        {table.getRowModel().rows.map(row => (
          <tr key={row.id}>
            {row.getVisibleCells().map(cell => (
              <td key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}
Enter fullscreen mode Exit fullscreen mode
Library Mutability pattern Compiler compatibility Recommended action
React Hook Form watch() mutates subscriptions Breaks without wrappers Use Controller, useFormState, or pass primitives
TanStack Table Row model mutates in place Breaks without opt-out Add "use no memo" or wrapper component
TanStack Virtual Virtualizer mutates scroll state Breaks without opt-out Add "use no memo" or wrapper component
MobX Observables mutate without React updates Breaks without observer Keep MobX observer wrapper
Immer Draft state mutates during produce Compatible Compiler understands Immer's immutability contract

MobX requires the observer higher-order component to track observable access and trigger re-renders. The compiler does not interfere with MobX's reactivity system, but teams using MobX should keep the observer wrapper and avoid relying on the compiler to memoize components that read observables.

Code Examples: When to Keep useMemo for Effect Dependencies and Exact Identity

Effect dependencies requiring exact identity represent the clearest case for keeping explicit useMemo. The compiler's automatic memoization optimizes for performance, not identity contracts. A useEffect that fetches data when a query configuration changes needs the configuration object to maintain the same reference across renders unless the underlying values change.

function SearchResults({ query, filters, sortBy }) {
  // Keep this useMemo: the effect needs exact identity
  const searchConfig = useMemo(
    () => ({
      query,
      filters,
      sortBy,
      includeArchived: false,
    }),
    [query, filters, sortBy]
  );

  useEffect(() => {
    const controller = new AbortController();

    fetch('/api/search', {
      method: 'POST',
      body: JSON.stringify(searchConfig),
      signal: controller.signal,
    })
      .then(res => res.json())
      .then(setResults);

    return () => controller.abort();
  }, [searchConfig]); // Effect fires only when config semantically changes

  return <ResultsList results={results} />;
}
Enter fullscreen mode Exit fullscreen mode

The alternative pattern flattens the dependency array and removes the object wrapper, making the identity contract explicit in the effect itself.

function SearchResults({ query, filters, sortBy }) {
  useEffect(() => {
    const controller = new AbortController();

    // No useMemo needed: dependencies are primitives
    fetch('/api/search', {
      method: 'POST',
      body: JSON.stringify({
        query,
        filters,
        sortBy,
        includeArchived: false,
      }),
      signal: controller.signal,
    })
      .then(res => res.json())
      .then(setResults);

    return () => controller.abort();
  }, [query, filters, sortBy]); // Effect tracks primitives directly

  return <ResultsList results={results} />;
}
Enter fullscreen mode Exit fullscreen mode

The second pattern eliminates the useMemo by removing the object from the dependency array entirely. The compiler handles the fetch call's memoization automatically, and the effect dependency array contains only primitives that React compares by value.

Effect dependency identity contract visualization

Effect dependency identity contract visualization

The decision between keeping useMemo and refactoring the dependency array depends on the team's tolerance for dependency-array length and the semantic clarity of the object wrapper. Configurations with six or more fields benefit from the object wrapper because the dependency array becomes unreadable. Configurations with two or three fields flatten easily.

Related patterns appear in event handlers passed to third-party libraries that compare handler references to optimize internal subscriptions. A charting library that subscribes to a onDataPointClick callback and unsubscribes on reference change needs the handler wrapped in useCallback to prevent subscription churn.

function InteractiveChart({ data }) {
  // Keep this useCallback: the library checks reference equality
  const handleDataPointClick = useCallback((point) => {
    analytics.track('chart_interaction', { pointId: point.id });
  }, []); // Empty deps: handler never changes

  return (
    <Chart
      data={data}
      onDataPointClick={handleDataPointClick}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

The compiler will memoize the handler automatically, but the explicit useCallback documents the identity contract for future maintainers. Removing it saves three lines of code at the cost of losing the semantic signal that the library depends on reference stability.

For additional context on managing state and effects in modern React applications, see our guides on building apps with just React hooks and atomic state management with Jotai.

Phase-by-Phase Migration Strategy: Installing, Testing, and Removing Hooks Safely

A safe migration starts with installing the compiler in a single low-risk component tree, validating behavior in production, and expanding coverage incrementally. The React Compiler documentation provides gating strategies, compatibility checks, and rollout tooling for gradual adoption.

Phase one installs the compiler and runs the ESLint plugin on the entire codebase. Developers fix Rules of React violations flagged by the plugin before enabling the compiler anywhere.

npm install --save-dev --save-exact babel-plugin-react-compiler@latest
npm install --save-dev eslint-plugin-react-hooks@latest
Enter fullscreen mode Exit fullscreen mode

Phase two enables the compiler for a single component tree in production and monitors render count, interaction latency, and error rate metrics for regressions. A low-risk tree contains no third-party library integrations, no complex effect dependencies, and no performance-critical paths that would make debugging difficult.

Phase three expands compiler coverage to the next component tree after confirming the first tree's metrics remain stable for a week. Teams with insufficient test coverage pin the compiler to an exact version using --save-exact to prevent SemVer updates from changing memoization behavior between deployments.

npm install --save-dev --save-exact babel-plugin-react-compiler@1.0.0
Enter fullscreen mode Exit fullscreen mode

The compiler documentation warns that future versions may change how memoization is applied, becoming more granular and precise. These changes optimize performance but can cause unexpected behavior in codebases that break the Rules of React in ways JavaScript cannot statically detect. Continuous end-to-end testing and exact version pinning mitigate this risk.

Phase four removes manual useMemo and useCallback hooks marked as performance-only during the audit. Developers submit small pull requests removing five to ten hooks at a time, run the test suite, and merge after confirming no behavioral changes. Identity-contract hooks remain permanently with comments explaining the contract.

function DataGrid({ rows, columns, onRowClick }) {
  // Identity contract: keep for effect dependency and third-party library
  const gridConfig = useMemo(
    () => ({ rows, columns, virtualizer: true }),
    [rows, columns]
  );

  // Performance: compiler handles (remove this hook)
  // const sortedRows = useMemo(() => rows.sort(compareRows), [rows]);

  return <VirtualizedTable config={gridConfig} onClick={onRowClick} />;
}
Enter fullscreen mode Exit fullscreen mode

Phase five audits library compatibility and adds "use no memo" directives to components using React Hook Form, TanStack, or MobX. These directives prevent the compiler from memoizing the component, preserving the library's interior mutability behavior.

For teams building accessible forms, our guide on useFormStatus and useFormState demonstrates patterns that work seamlessly with the compiler.

Frequently Asked Questions

Can the React Compiler break production if enabled globally without testing?

Yes. The compiler changes how memoization is applied and may expose latent bugs in code that violates the Rules of React. Effect dependencies relying on exact identity, libraries with interior mutability, and components with conditional hook calls will produce unexpected behavior. Enable the compiler incrementally, starting with low-risk component trees and monitoring production metrics.

Do I need to remove all useMemo and useCallback hooks immediately after installing the compiler?

No. The compiler works correctly with existing memoization hooks in place. Remove hooks incrementally after confirming they serve performance-only purposes. Keep hooks that enforce identity contracts for effect dependencies or third-party library requirements. Removing all hooks at once increases the risk of missing semantic contracts.

Which version of React do I need to use the compiler?

The compiler supports React 17 and up. Teams on React 17 or 18 must specify a minimum target in the compiler config and add react-compiler-runtime as a dependency. React 19 includes native compiler support without additional runtime dependencies.

How do I know if a third-party library is compatible with the compiler?

Check the library's documentation for compiler compatibility notes. Libraries with interior mutability (React Hook Form, TanStack Table, MobX) require component-level opt-outs or wrapper patterns. Run the ESLint plugin to identify Rules violations in library integration code. When unsure, add a "use no memo" directive to the component and test behavior before and after.

What happens if the compiler skips a component due to a Rules violation?

The compiler silently skips components that violate the Rules of React and does not apply automatic memoization. The component continues to work but receives no performance optimization. The ESLint plugin from eslint-plugin-react-hooks@latest flags these violations during development, allowing developers to fix them before deployment.

Conclusion

The React Compiler eliminates manual memoization for performance but leaves developers responsible for identity contracts, library compatibility, and Rules of React compliance. Teams running the ESLint audit first, cataloging effect dependencies second, and enabling the compiler incrementally gain performance improvements without breaking production. Every useMemo that remains after migration should document the specific identity contract or library requirement it enforces. The compiler handles the rest.

Top comments (0)