5
minutes
Mis à jour le
28/11/2024


Share this post

We all strive for fast, high-performing React applications, but manual optimizations often lead to unintentional regressions. A compiler can automate these optimizations, and this article explains why it’s a game changer and how to adopt it incrementally.

#
React
#
React Native
Vincent Escoffier
Software Engineer

Introduction

React is built around a simple yet powerful concept: UI as a function of state. For this reason, React's design intentionally doesn't track data access during renders, which differentiates it from libraries like SolidJS or Vue that offer fine-grained reactivity out of the box. As our applications grow in complexity, performance bottlenecks due to update costs (re-renders) often become a significant challenge. In every React app I've worked on, the discussion about re-renders inevitably arises, yet a fully satisfying solution remains elusive.

While built-in features like automatic batching, useCallback, and memo have addressed performance issues in many cases, they often fall short for more complex scenarios. The community has stepped in with valuable user-land solutions such as Zustand, Redux, or Preact Signals for state management, but unfortunately this sidesteps a lot of what React can do for you. For me, it always seemed that React was missing a part—until now.

The React team has long envisioned a compiler-based approach to automatically optimize React applications. This vision is materializing with the React Compiler (previously known as "React Forget"). This approach aims to provide the benefits of fine-grained updates without changing React's familiar programming model: developers write the same code, but it runs faster. Although still under construction, this compiler is being used extensively in production at Meta, and you can already prepare the ground for its adoption.

I hope this article will highlight why the compiler is a game-changer for both performance and stability in React applications. We'll delve into the problem it solves and provide practical steps for incrementally adopting it in your codebase.

A quick review of existing solutions

In React, when a component's state (its state, props, or context) changes, React will re-render that component and all of its children unless you applied some form of manual memoization with useMemo(), useCallback(), or React.memo(). If you're not familiar with those patterns, I recommend that you read first this article about re-renders.

That said, most of the time, you don't need to worry about update costs because your re-rendering logic is fast or because you can optimize your architecture instead. But if you find yourself in a legitimate case for memoization, you will rapidly get into some trouble.

Optimization is vulnerable to changes.

The biggest pain point with manual optimizations like useMemo(), useCallback(), or React.memo() is that a single “always new” value can break memoization for an entire component, rendering the optimization chain vulnerable to even minor changes.

source: https://x.com/shuding_/status/1844678879748723059

As your application grows, it becomes very easy to overlook a memoization case and inadvertently break existing optimizations. While this is already challenging for a single developer to manage, it becomes nearly impossible to enforce consistently within a large team with varying levels of React expertise. It is too easy to unintentionally introduce performance regressions that would only get caught during QA or, worse, by your users.

Some might argue that the solution is to memoize everything, every time—but this approach introduces its own set of challenges, leading us to the next pain point.

Optimized React is Hard to Read

The challenge with React lies in how the default way of writing code—the one that feels intuitive—is often not the most optimized approach.

For instance, you might start by writing a component like this. Notice that there’s nothing specifically React-related here; it’s essentially “just JavaScript (XML).”

At some point, you might need to stabilize references to enable memoization:

Here, you’ve introduced new React-specific concepts, which add cognitive complexity. As a result, the elegance of the earlier code is lost. This has long been a valid criticism of React. In other frameworks that embraced signals like Vue, Svelte, Angular, or Solid, your code is optimized by default—at least from the perspective of fine-grained reactivity.

External store

For these reasons, you may decide to use React purely as a view layer, delegating all state management to an external store like Zustand or Redux. These stores typically offer fine-grained updates out of the box.

While such solutions have proven to be valuable, they are not entirely satisfying. They require stepping outside of React's model, which means missing out on many features React provides, such as transitions or useDeferredValue. Additionally, external stores don’t always integrate seamlessly with features like Suspense.

The compiler approach

Now that we have a clear vision of our goal, we aim to write the same clean, standard JavaScript code as before—without the burden of manual optimizations or the hassle of managing dependency arrays. This is precisely where a compiler shines! In fact, the React team had this idea in mind as far back as the introduction of hooks and the dependency array pattern, as highlighted here.

The React Compiler is a build-time tool designed to automatically optimize your React applications regarding re-renders. Leveraging JavaScript and the Rules of React, the compiler automates the memoization process by identifying and stabilizing values or groups of values within your components and hooks. When it encounters a rule-breaking scenario, the compiler gracefully skips over the problematic components or hooks, ensuring that the rest of your code is compiled safely and efficiently.

This approach directly addresses the challenges outlined earlier in this article, such as the fragility of manual optimizations and the complexity they introduce. While the compiler may not yield significant performance gains for already well-memoized codebases, achieving such meticulous manual memoization is notoriously difficult and error-prone. As discussed, even a single oversight can break the entire optimization chain. The compiler eliminates this risk by automating the process, allowing developers to write clean, idiomatic React code without worrying about dependency arrays or unstable references.

By automating memoization, the React Compiler brings the simplicity and ease of use found in frameworks with fine-grained reactivity—like SolidJS or Vue—while staying true to React’s familiar programming model.

How to adopt it

React compiler is currently provided through a Babel plugin, and the minimum supported version is React 17.

In order to apply optimizations, the compiler assumes that your code:

  1. Is valid, semantic JavaScript
  2. Tests that nullable/optional values and properties are defined before accessing them (for example, by enabling strictNullChecks if using TypeScript)
  3. Follows the Rules of React

Those are bold assumptions, and it’s likely that your code doesn't fully align with them yet. You can check which components or hooks break the rules of React using the eslint-plugin-react-compiler. This plugin serves as a linter for React-specific language rules, such as avoiding prop mutations. The linter does not require that you have the compiler installed, so you can use it even if you are not ready to try out the compiler, and it is strongly recommended to do so.

These rules aren’t new—they’ve always been a part of React’s design, but they’ve often been treated as implicit guidelines. However, the React Compiler provides a compelling reason to take these rules seriously: adhering to them not only makes your code more robust but also enables the compiler to optimize it for better performance.

React Compiler can verify many of the rules of React statically and will skip compilation when it detects an error. When the compiler detects a violation of these assumptions, it safely skips over the offending component or hook and continues compiling the rest of your code. This means that you don’t have to fix all ESLint violations straight away. You can address them at your own pace to increase the amount of components and hooks being optimized, but it is not required to fix everything before you can use the compiler.

The compiler team suggests the following approach:

  1. Make it work
    • Try to compile as much of your app as possible, and verify that it's not broken.
      • For example, by running it on a small directory in your product code first. You can do this by configuring the compiler to only run on a specific set of directories:
    • Measure if performance has improved.
  2. Make it right
  3. Make it faster
    • You may be able to get this step "for free" if you have fixed rules of React violations, since the compiler can now optimize them!

Adopting the compiler can help to not only improve the performance but also uncover hidden bugs, resulting in faster runtime performance and a cleaner, more reliable codebase.

One important takeaway from this process is the need to measure performance improvements systematically. Having a clear metric or benchmark in place allows you to quantify the benefits of the compiler and track progress over time. Tools like those by https://callstack.github.io/reassure/ and automated user flows can help with that. In fact, this will be an excellent topic for a future article.