React

Testing the React Compiler ahead of its stable release

RCT

The React Compiler's core promise, automatically memoizing components and values so developers stop hand-writing useMemo and useCallback everywhere, is a big claim, and one we were skeptical of after years of watching automatic-optimization promises fall short of hand-tuned code in practice. We enabled it on a real internal app ahead of its stable release to see how much of that promise already holds up under actual production-like conditions rather than a curated demo.

What the compiler actually does

The compiler analyzes component code at build time and inserts memoization automatically wherever it can prove doing so is safe, effectively generating the useMemo and useCallback calls a careful developer would have written by hand, without requiring any change to how the component is written. This matters because manual memoization has always had an inherent tension: over-memoize and you add overhead and complexity for no benefit, under-memoize and you get unnecessary re-renders, and getting the balance right by hand requires the kind of careful, ongoing attention that most teams don't consistently apply across an entire codebase.

We picked an internal admin dashboard for the trial, a reasonable middle ground between a toy example and a customer-facing app where a regression would be costly. It has enough component complexity, deeply nested tables, filterable lists, live-updating widgets, to genuinely exercise the compiler's optimization logic rather than trivially succeeding on simple components that wouldn't have needed memoization in the first place.

What we found

A meaningful share of our existing manual memoization turned out to be redundant once the compiler was handling it automatically, and removing it simplified the code without a performance regression. We measured roughly a third of our useMemo and useCallback calls across the dashboard as safe to delete after enabling the compiler, verified by comparing render counts before and after removal using React's built-in profiler. That's a real simplification, not just a style preference; several components got noticeably easier to read once the manual memoization boilerplate was gone.

  • A meaningful share of our existing manual memoization turned out to be redundant once the compiler was handling it automatically, and removing it simplified the code without a performance regression.
  • A few components with unusual patterns didn't get optimized as effectively as our hand-tuned version, which the compiler's own bailout warnings correctly flagged rather than silently under-optimizing.
  • Build times increased modestly, on the order of ten to fifteen percent, due to the additional static analysis pass the compiler performs on every build.

A few components with unusual patterns didn't get optimized as effectively as our hand-tuned version, which the compiler's own bailout warnings correctly flagged. These were mostly components using patterns the compiler can't safely reason about, mutating a ref inside a render path in a way that technically violates React's rules but had worked in practice, or relying on object identity in a way that wasn't obviously stable to static analysis. The compiler's decision to bail out conservatively rather than guess wrong felt like the right tradeoff; a wrong guess would silently produce a stale-render bug that could be far harder to track down than a missed optimization.

The cost side of the ledger

Build times increased modestly, an increase we noticed within the first week and confirmed wasn't a fluke across several subsequent builds, due to the additional static analysis pass the compiler performs on every build. For our CI pipeline this added roughly twenty seconds to an already multi-minute build, which we judged acceptable given the runtime benefits, but it's worth budgeting for on a project with much larger builds where that percentage increase would translate into meaningfully more wall-clock time.

We also hit a handful of compiler warnings on code that violated React's rules of hooks in subtle ways that had never caused a visible bug, exactly the kind of latent issue that automatic tooling is well-suited to surface even when it doesn't change behavior immediately. Fixing those felt like a genuine quality improvement independent of any performance gain from the compiler itself.

What we'd do differently

We're not ready to strip out all manual memoization in production code yet, but the compiler's optimization coverage is already good enough that new code doesn't need it by default anymore, which is a meaningful shift in how we're writing components going forward. If we were rolling this out again, we'd start the trial on a smaller, less critical component tree first rather than an entire dashboard, purely to get a faster read on bailout patterns before committing more engineering time to interpreting warnings across a larger surface area. We'd also set up the before-and-after render count comparison from day one rather than building that measurement approach partway through the trial; having concrete numbers rather than a subjective sense of "it feels about the same" made the case to the rest of the team far more convincing than anecdote would have.

Adopting the compiler's linting rules

Alongside the compiler itself, we adopted its accompanying ESLint plugin, which flags code patterns the compiler can't safely optimize even before you enable the compiler in a build. Running the lint rule across the rest of our codebase, well beyond the dashboard we'd chosen for the actual compiler trial, surfaced a useful map of where else in the app the compiler would likely bail out if we turned it on more broadly. A good number of the flagged patterns were genuinely worth fixing independent of the compiler, mutable module-level state being read inside a component being the most common offender, since that pattern is a latent source of confusing bugs regardless of whether a compiler is trying to optimize around it.

How this changes new component guidance

We updated our internal component style guide to reflect the compiler's presence, mostly by removing guidance that no longer applies rather than adding new rules. The old guidance included a fairly detailed section on when to reach for useMemo versus useCallback, complete with a small decision tree, that new engineers found genuinely confusing to apply consistently. That section is now mostly historical context rather than a requirement, since the compiler handles the common cases automatically and the remaining manual memoization guidance has shrunk to a short note about the specific patterns the compiler currently can't optimize, mutation of refs during render being the main one worth calling out explicitly.

Watching for regressions over time

Because the compiler is still ahead of its stable release, we're treating every dependency upgrade to it as a change worth re-testing rather than a routine bump, at least until it reaches general availability and its behavior stabilizes further. We added a small automated check to our CI pipeline that flags any component where the compiler's bailout warnings increase after a dependency update, which has already caught one case where an upstream library update introduced a pattern the compiler couldn't optimize, in time to investigate before it became a broader performance regression across the app.

← Back to the journal

Have a project in mind?
Let’s talk.

Tell us where you are and where you want to go. We'll map the fastest route between the two.

Currently accepting new clients