+1 (415) 943-4271

React Compiler in React Native: Turning It On Without Breaking Your App

Most React Native performance work we get called in for is not exotic. It is a list that stutters, a form that drops keystrokes, and a screen that re-renders its entire tree because one context value changed. The traditional fix is a careful sprinkling of useMemo, useCallback and React.memo — code that is tedious to write, easy to get subtly wrong, and rots the moment someone adds a prop.

React Compiler is the attempt to delete that work. It is a build-time compiler that analyses your components and inserts the memoisation for you. It is now stable enough to be a real option in React Native, and it ships in recent versions behind an opt-in flag. This is how we turn it on for clients, and what we check before and after.

What the compiler actually does

The compiler rewrites each component and hook so that intermediate values, JSX elements and callbacks are cached in a hidden slot array keyed by their dependencies. Roughly, it does automatically and exhaustively what a very disciplined engineer would do by hand:

  • inline objects and arrays passed as props stop being new identities on every render;
  • event handlers defined in the body stop invalidating memoised children;
  • expensive derived values are recomputed only when their inputs change;
  • child subtrees whose props are unchanged skip re-rendering, without you wrapping them in React.memo.

What it does not do is make a slow render fast. If one component does 40ms of layout work, the compiler will happily skip that work when inputs are unchanged — but the first render, and every render where inputs do change, costs exactly what it did before. Compiler adoption is a re-render problem solver, not a substitute for virtualised lists, image sizing, or moving work off the JS thread.

Prerequisite: your code has to follow the rules

The compiler only memoises components it can prove are safe. Code that breaks the Rules of React — mutating props or state objects, reading refs during render, calling hooks conditionally — gets bailed out: the compiler skips that component and leaves it exactly as it was. That is the good news (it fails safe) and the bad news (you can enable it and get almost nothing).

So step zero is the lint rule, not the config flag:

npm i -D eslint-plugin-react-hooks@latest
// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';

export default [
  reactHooks.configs.recommended,
  {
    rules: {
      // surfaces every component the compiler would bail out of
      'react-hooks/react-compiler': 'error',
    },
  },
];

Run it across the codebase and count the errors. On a typical five-year-old React Native app we see somewhere between a handful and a few hundred. The common offenders, in order of how often we hit them:

  1. Mutating a prop or state value in placeitems.sort() instead of [...items].sort().
  2. Reading or writing ref.current during render rather than in an effect or handler.
  3. Assigning to module-level variables from a render body (caches, counters, "did I already log this" flags).
  4. Conditional hook calls hidden behind an early return null.

Fixing these is worth doing whether or not you ever enable the compiler; every one of them is a real bug waiting for concurrent rendering to expose it.

Turning it on

Expo (SDK 52+):

{
  "expo": {
    "experiments": {
      "reactCompiler": true
    }
  }
}
npx expo install babel-plugin-react-compiler

Bare React Native, via Babel:

npm i -D babel-plugin-react-compiler
// babel.config.js
module.exports = {
  presets: ['module:@react-native/babel-preset'],
  plugins: [
    ['babel-plugin-react-compiler', { target: '19' }],
    // react-native-reanimated/plugin must stay LAST
    'react-native-reanimated/plugin',
  ],
};

Two things bite people here. First, plugin order: Reanimated's Babel plugin has to remain the last entry, and the compiler plugin should sit above it. Second, the target option must match the React version actually in your lockfile — if you are on React 18 you need the react-compiler-runtime shim installed, otherwise the compiled output references hooks that do not exist and you get a white screen with a cryptic dispatcher error.

After changing Babel config, clear the bundler cache (npx expo start -c, or npx react-native start --reset-cache). Stale transform caches are responsible for at least half the "the compiler did nothing" reports we investigate.

Roll it out per directory, not all at once

On a large app we never flip it on globally on day one. The compiler supports opting in by path:

['babel-plugin-react-compiler', {
  target: '19',
  sources: (filename) => filename.includes('/src/features/checkout/'),
}]

Pick one busy, well-tested screen — a feed, a checkout, a settings form — ship it to internal testers for a week, then widen. This keeps any regression attributable to a small diff, which matters a lot when you are doing this inside a client's release train.

Verifying that it worked

"Feels snappier" is not a result. Three checks we run:

1. Did it compile? Add the React DevTools profiler and look for the ✨ sparkle badge next to compiled components, or dump the compiler's own output:

npx react-compiler-healthcheck@latest

It reports how many components were successfully compiled versus bailed out, and flags incompatible libraries. Anything below ~80% compiled means you have lint debt to pay first.

2. Count renders, before and after. The cheapest instrumentation that survives contact with a real app:

import { Profiler } from 'react';

<Profiler
  id="ProductList"
  onRender={(id, phase, actualDuration) => {
    if (__DEV__) console.log(id, phase, Math.round(actualDuration));
  }}
>
  <ProductList />
</Profiler>

Record the same scripted interaction (open screen, scroll to item 50, type in the filter) with the flag off and on. You are looking for fewer update entries, not smaller actualDuration on the mount.

3. Watch the release build, not Metro dev mode. Dev-mode React Native is dominated by bridge logging and dev tooling overhead. Measure a release build on a mid-range Android device — the one your users actually have.

Where it disappoints

Be honest with stakeholders about the ceiling:

  • Third-party components you do not compile still re-render on their own terms. A heavy chart or map library is unaffected.
  • List performance is dominated by virtualisation and cell layout cost. Compiler memoisation helps the row content, but a badly configured FlashList will still drop frames.
  • Context still propagates. The compiler can skip subtrees whose props are unchanged, but a context value that changes identity on every render will still wake every consumer. Splitting or stabilising context values remains manual work.
  • Build times go up, modestly. On a large monorepo, budget for a slower cold bundle.

We also keep existing manual useMemo/useCallback in place rather than ripping it out in the same PR. The compiler tolerates them, and removing them is a separate, reviewable cleanup once you trust the output.

A pragmatic adoption checklist

  1. Upgrade eslint-plugin-react-hooks and enable the compiler rule; fix the errors.
  2. Run react-compiler-healthcheck to get a baseline compile rate.
  3. Enable the Babel plugin scoped to one feature directory; clear the cache.
  4. Profile that screen before and after in a release build on a real mid-range device.
  5. Widen the sources filter directory by directory across a few releases.
  6. Only then consider deleting hand-written memoisation, in its own PR.

Done this way, React Compiler is one of the lowest-risk performance wins available to a mature React Native codebase — and the lint cleanup it forces is usually worth more than the memoisation itself.


Working through a React Native performance review, an upgrade, or a New Architecture migration? Get in touch — our senior React Native consultants do this on client codebases every week.