Nadia Makarevich

I tried React Compiler today, and guess what... šŸ˜‰

Investigating whether we can forget about memoization in React, now that the React Compiler is open-sourced.
I tried React Compiler today, and guess what... šŸ˜‰

This is probably the most clickbaity title Iā€™ve come up with, but I feel like an article about one of the most hyped topics in the React community these days deserves it šŸ˜….

For the last two and a half years, after I release any piece of content that mentions patterns related to re-renders and memoization, visitors from the future would descend into the comments section and kindly inform me that all I just said is not relevant anymore because of React Forget (currently known as React Compiler).

Now that our timeline has finally caught up with theirs and React Compiler is actually released to the general public as an experimental feature, itā€™s time to investigate whether those visitors from the future are correct and see for ourselves whether we can forget about memoization in React starting now.

What is React Compiler

But first, very, very briefly, what is this compiler, what problem does it solve, and how do you get started with it?

The problem: Re-renders in React are cascading. Every time you change state in a React component, you trigger a re-render of that component, every component inside, components inside of those components, etc., until the end of the component tree is reached.

re-renders-example-20220802-132550.png

If those downstream re-renders affect some heavy components or happen too often, this might cause performance problems for our apps.

One way to fix those performance problems is to prevent that chain of re-renders from happening, and one way to do that is with the help of memoization: React.memo, useMemo, and useCallback. Typically, weā€™d wrap a component in React.memo, all of its props in useMemo and useCallback, and next time, when the parent component re-renders, the component wrapped in memo (i.e., ā€œmemoizedā€) wonā€™t re-render.

But using those tools correctly is hard, very hard. Iā€™ve written a few articles and done a few videos on this topic if you want to test your knowledge of it (How to useMemo and useCallback: you can remove most of them, Mastering memoization in React - Advanced React course, Episode 5).

This is where React Compiler comes in. The compiler is a tool developed by the React core team. It plugs into our build system, grabs the original components' code, and tries to convert it into code where components, their props, and hooks' dependencies are memoized by default. The end result is similar to wrapping everything in memo, useMemo, or useCallback.

This is just an approximation to start wrapping our heads around it, of course. In reality, it does much more complicated transformations. Jack Herrington did a good overview of that in his recent video (React Compiler: In-Depth Beyond React Conf 2024), if you want to know the actual details. Or, if you want to break your brain completely and truly appreciate the complexity of this, watch the ā€œReact Compiler Deep Diveā€ talk where Sathya Gunasekaran explains the Compiler and Mofei Zhang then live-codes it in 20 minutes šŸ¤Æ.

If you want to try out the Compiler yourself, just follow the docs: https://react.dev/learn/react-compiler. They are good enough already and have all the requirements and how-to steps. Just remember: this is still a very experimental thing that relies on installing the canary version of React, so be careful.

Thatā€™s enough of the preparation. Letā€™s finally look at what it can do and how it performs in real life.

Trying out the Compiler

For me, the main purpose of this article was to investigate whether our expectations of the Compiler match reality. What is the current promise?

  • The Compiler is plug-and-play: you install it, and it Just Works; there is no need to rewrite existing code.
  • We will never think about React.memo, useMemo, and useCallback again after itā€™s installed: there wonā€™t be any need.

To test those assumptions, I implemented a few simple examples to test the Compiler in isolation and then ran it on three different apps I have available.

Simple examples: testing the Compiler in isolation

The full code of all the simple examples is available here: https://github.com/developerway/react-compiler-test

The easiest way to start with the Compiler from scratch is to install the canary version of Next.js. Basically, this will give you everything you need:

npm install next@canary babel-plugin-react-compiler

Then we can turn the Compiler on in the next.config.js:

const nextConfig = {
experimental: {
reactCompiler: true,
},
};
module.exports = nextConfig;

And voila! Weā€™ll immediately see auto-magically memoized components in React Dev Tools.

memo-in-dev-tools-20240605-060936.png

The assumption one is correct so far: installing it is pretty simple, and it Just Works.

Letā€™s start writing code and see how the Compiler deals with it.

First example: simple state change.

const SimpleCase1 = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
toggle dialog
</button>
{isOpen && <Dialog />}
<VerySlowComponent />
</div>
);
};

We have an isOpen state variable that controls whether a modal dialog is open or not, and a VerySlowComponent rendered in the same component. Normal React behavior would be to re-render VerySlowComponent every time the isOpen state changes, leading to the dialog popping up with a delay.

Typically, if we want to solve this situation with memoization (although there are other ways, of course), weā€™d wrap VerySlowComponent in React.memo:

const VerySlowComponentMemo = React.memo(VerySlowComponent);
const SimpleCase1 = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
...
<VerySlowComponentMemo />
</>
);
};

With the Compiler, itā€™s pure magic: we can ditch the React.memo, and still see in the dev tools that the VerySlowComponent is memoized, the delay is gone, and if we place console.log inside the VerySlowComponent, weā€™ll see that indeed, itā€™s not re-rendered on state change anymore.

The full code of these examples is available here.

Second example: props on the slow component.

So far so good, but the previous example is the simplest one. Letā€™s make it a bit more complicated and introduce props into the equation.

Letā€™s say our VerySlowComponent has an onSubmit prop that expects a function and a data prop that accepts an array:

const SimpleCase2 = () => {
const [isOpen, setIsOpen] = useState(false);
const onSubmit = () => {};
const data = [{ id: 'bla' }];
return (
<>
...
<VerySlowComponent onSubmit={onSubmit} data={data} />
</>
);
};

Now, in the case of manual memoization, on top of wrapping VerySlowComponent in React.memo, weā€™d need to wrap the array in useMemo (letā€™s assume we canā€™t just move it outside for some reason) and onSubmit in useCallback:

const VerySlowComponentMemo = React.memo(VerySlowComponent);
export const SimpleCase2Memo = () => {
const [isOpen, setIsOpen] = useState(false);
// memoization here
const onSubmit = useCallback(() => {}, []);
// memoization here
const data = useMemo(() => [{ id: 'bla' }], []);
return (
<div>
...
<VerySlowComponentMemo
onSubmit={onSubmit}
data={data}
/>
</div>
);
};

But with the Compiler, we still donā€™t need to do that! VerySlowComponent still appears as memoized in React dev tools, and the ā€œcontrolā€ console.log inside it is still not fired.

You can run these examples locally from this repo.

Third example: elements as children.

Okay, the third example, before testing a real app. What about the case where almost no one can memoize correctly? What if our slow component accepts children?

export const SimpleCase3 = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
...
<VerySlowComponent>
<SomeOtherComponent />
</VerySlowComponent>
</>
);
};

Can you, off the top of your head, remember how to memoize VerySlowComponent correctly here?

Most people would assume that weā€™d need to wrap both VerySlowComponent and SomeOtherComponent in React.memo. This is incorrect. We'd need to wrap our <SomeOtherComponent /> element into useMemo instead, like this:

const VerySlowComponentMemo = React.memo(VerySlowComponent);
export const SimpleCase3 = () => {
const [isOpen, setIsOpen] = useState(false);
// memoize children via useMemo, not React.memo
const child = useMemo(() => <SomeOtherComponent />, []);
return (
<>
...
<VerySlowComponentMemo>{child}</VerySlowComponentMemo>
</>
);
};

If youā€™re unsure why this is the case, you can watch this video that explains memoization in detail, including this pattern: Mastering memoization in React - Advanced React course, Episode 5. This article can also be useful: The mystery of React Element, children, parents and re-renders

Luckily, the React Compiler still works its magic āœØ here! Everything is memoized, the very slow component doesnā€™t re-render.

Three hits out of three so far, thatā€™s impressive! But those examples are very simple. Whenā€™s life that easy in reality? Letā€™s try a real challenge now.

Testing the Compiler on real code

To really challenge the Compiler, I ran it on three codebases I have available:

  • App One: A few years old and quite large app, based on React, React Router & Webpack, written by multiple people.
  • App Two: Slightly newer but still quite large React & Next.js app, written by multiple people.
  • App Three: My personal project: very new, latest Nextjs, very small - a few screens with CRUD operations.

For every app, I did:

  • initial health check to determine the readiness of the app for the Compiler.
  • enabled Compilerā€™s eslint rules and ran them on the entire codebase.
  • updated React version to 19 canary.
  • installed the Compiler.
  • identified some visible cases of unnecessary re-renders before turning on the Compiler.
  • turned on the Compiler and checked whether those unnecessary re-renders were fixed.

Testing the Compiler on App One: results

This one is the biggest, probably around 150k lines of code for the React part of the app. I identified 10 easy-to-spot cases of unnecessary re-renders for this app. Some were pretty minor, like re-rendering a whole header component when clicking a button inside. Some were bigger, like re-rendering the entire page when typing in an input field.

  • Initial health check: 97.7% of the components could be compiled! No incompatible libraries.
  • Eslint check: just 20 rule violations
  • React 19 update: a few minor things broke, but after commenting them out, the app seemed to be working fine.
  • Installing the Compiler: this one produced a few F-bombs and required some help from ChatGPT since itā€™s been a while since I last touched anything Webpack or Babel-related. But in the end, it also worked.
  • Testing the app: out of 10 cases of unnecessary re-renders ā€¦ only 2 were fixed by the Compiler šŸ˜¢

2 out of 10 was a pretty disappointing result. But this app had some eslint violations that I havenā€™t fixed, maybe thatā€™s why? Letā€™s take a look at the next app.

Testing the Compiler on App Two: results

This app is much smaller, something like 30k lines of React code. Here I also identified 10 unnecessary re-renders.

  • Initial health check: Same result, 97.7% components could be compiled.
  • Eslint check: just 1 rule violation! šŸŽ‰Perfect candidate.
  • React 19 update & installing the Compiler: for this, I had to update Next.js to the canary version, it took care of the rest. It just worked after the installation, was much easier than updating the Webpack-based app.
  • Testing the app: out of 10 cases of unnecessary re-rendersā€¦ only 2 again were fixed by the compiler šŸ˜¢

2 out of 10 again! On a perfect candidateā€¦ Again, a bit disappointing. Thatā€™s real life against synthetic ā€œcounterā€ examples for you. Letā€™s take a look at the third app before trying to debug whatā€™s going on.

Testing the Compiler on App Three: results

This is the smallest of them all, written in a weekend or two. Just a few pages with a table of data, and the ability to add/edit/remove an entity in the table. The entire app is so small and so simple that I was able to identify only 8 unnecessary re-renders in it. Everything re-renders on every interaction there, I havenā€™t optimized it in any way.

Perfect subject for the React Compiler to drastically improve the re-renders situation!

  • Initial health check: 100% of components can be compiled
  • Eslint check: no violations šŸŽ‰
  • React 19 update & installing the Compiler: surprisingly worse than the previous one. Some of the libraries that I used were not compatible with React 19 yet. I had to force-install the dependencies to silence the warnings. But the actual app and all the libraries still worked, so no harm, I guess.
  • Testing the app: out of 8 cases of unnecessary re-renders, the React Compiler managed to fixā€¦ drum rollā€¦ one. Only one! šŸ«  At this point, I almost started crying; I had such hopes for this test.

This is something that my old cynical nature expected, but definitely not something that my naive inner child was hoping for. Maybe Iā€™m just writing React code wrong? Can I investigate what went wrong with memoization by the Compiler, and can it be fixed?

Investigating the results of memoization by the Compiler

To debug these issues in a useful manner, I extracted one of the pages from the third app into its own repo. You can check it out here: (https://github.com/developerway/react-compiler-test/ ) if you want to follow my train of thought and do a code-along exercise. Itā€™s almost exactly one of the pages I have in the third app, just with fake data and a few things removed (like SSR) to simplify the debugging experience.

The UI is very simple: a table with a list of countries, a ā€œdeleteā€ button for each row, and an input component under the table where you can add a new country to the list.

third-app-ui-screenshot-20240605-063231.png

From the code perspective, itā€™s just one component with one state, queries, and mutations. Hereā€™s the full code. The simplified version with only the necessary information for the investigation looks like this:

export const Countries = () => {
// store what we type in the input here
const [value, setValue] = useState("");
// get the full list of countries with react-query
const { data: countries } = useQuery(...);
// mutation to delete a country with react-query
const deleteCountryMutation = useMutation(...);
// mutation to add a country with react-query
const addCountryMutation = useMutation(...);
// callback that is passed to the "delete" button
const onDelete = (name: string) => deleteCountryMutation.mutate(name);
// callback that is passed to the "add" button
const onAddCountry = () => {
addCountryMutation.mutate(value);
setValue("");
};
return (
...
{countries?.map(({ name }, index) => (
<TableRow key={`${name.toLowerCase()}`}>
...
<TableCell className="text-right">
<!-- onDelete is here -->
<Button onClick={() => onDelete(name)} variant="outline">
Delete
</Button>
</TableCell>
</TableRow>
))}
...
<Input
type="text"
placeholder="Add new country"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<button onClick={onAddCountry}>Add</button>
);
};

Since itā€™s just one component with multiple states (local + query/mutation updates), everything re-renders on every interaction. If you start the app, youā€™ll have these cases of unnecessary re-renders:

  • typing into the ā€œAdd new countryā€ input causes everything to re-render.
  • clicking ā€œdeleteā€ causes everything to re-render.
  • clicking ā€œaddā€ causes everything to re-render.

For a simple component like this, Iā€™d expect the Compiler to fix all of this. Especially considering that in the React Dev Tools, everything seems to be memoized:

4-everything-memoized-20240605-092740.png

However, try enabling the ā€œHighlight updates when components renderā€ setting and enjoy the light show.

5-everything-re-renders-20240605-093649.gif

Adding console.log to every component inside the table gives us the exact list: everything except for the header components still re-renders on every state update from all sources.

How to investigate why, though? šŸ¤”

React Dev Tools doesnā€™t give any additional information. I could copy-paste that component into the Compiler Playground and see what happensā€¦ But take a look at the output! šŸ˜¬ That feels like a step in the wrong direction, and to be frank, the last thing I want to do, ever.

The only thing that comes to mind is to incrementally memoize that table and see whether something fishy is going on with components or dependencies.

Investigating via manual memoization

This part is for those who fully understand how all manual memoization techniques work. If youā€™re feeling uneasy about React.memo, useMemo, or useCallback, I recommend watching this video first.

Also, Iā€™d recommend opening the code locally (https://github.com/developerway/react-compiler-test ) and doing a code-along exercise; it would make following the train of thought below much easier.

Investigating typing into input re-renders

Letā€™s look at that table again, this time in full:

<Table>
<TableCaption>Supported countries list.</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[400px]">Name</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{countries?.map(({ name }, index) => (
<TableRow key={`${name.toLowerCase()}`}>
<TableCell className="font-medium">
<Link href={`/country/${name.toLowerCase()}`}>
{name}
</Link>
</TableCell>
<TableCell className="text-right">
<Button
onClick={() => onDelete(name)}
variant="outline"
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>

The fact that header components were memoized hints to us what the Compiler did: it probably wrapped all components in a React.memo equivalent, and the part inside TableBody is memoized with a useMemo equivalent. And the useMemo equivalent has something in its dependencies that is updated with every re-render, which in turn causes everything inside TableBody to re-render, including TableBody itself. At least itā€™s a good working theory to test.

If I replicate the memoization of that content part, it might give us some clues:

// memoize the entire content of TableBody
const body = useMemo(
() =>
countries?.map(({ name }, index) => (
<TableRow key={`${name.toLowerCase()}`}>
<TableCell className="font-medium">
<Link href={`/country/${name.toLowerCase()}`}>
{name}
</Link>
</TableCell>
<TableCell className="text-right">
<Button
onClick={() => onDelete(name)}
variant="outline"
>
Delete
</Button>
</TableCell>
</TableRow>
)),
// these are the dependencies used in that bunch of code
// thank you eslint!
[countries, onDelete],
);

Now itā€™s clearly visible that this entire part depends on the countries array of data and the onDelete callback. The countries array is coming from a query, so it canā€™t possibly be re-created on every re-render - caching this is one of the primary responsibilities of the library.

The onDelete callback looks like this:

const onDelete = (name: string) => {
deleteCountryMutation.mutate(name);
};

In order for it to go into the dependencies, it should be memoized as well:

const onDelete = useCallback(
(name: string) => {
deleteCountryMutation.mutate(name);
},
[deleteCountryMutation],
);

And deleteCountryMutation is a mutation from react-query again, so itā€™s likely okay:

const deleteCountryMutation = useMutation({...});

The final step is to memoize the TableBody and render the memoized child. If everything is memoized correctly, then re-rendering of rows and cells when typing in the input should stop.

const TableBodyMemo = React.memo(TableBody);
// render that inside Countries
<TableBodyMemo>{body}</TableBodyMemo>;

Aaaand, it didnā€™t work šŸ¤¦šŸ»ā€ā™€ļø Now weā€™re getting somewhere - I messed something up with the dependencies, and the Compiler probably did the same. But what? Aside from countries, I only have one dependency - deleteCountryMutation. I made an assumption that itā€™s safe, but is it really? Whatā€™s actually inside? Luckily, the source code is available. useMutation is a hook that does a bunch of things and returns this:

const mutate = React.useCallback(...)
return { ...result, mutate, mutateAsync: result.mutate }

Itā€™s a non-memoized object in the return!! I was wrong in my assumption that I could just use it as a dependency.

mutate itself is memoized, however. So in theory, I just need to pass it to the dependencies instead:

// extract mutate from the returned object
const { mutate: deleteCountry } = useMutation(...);
// pass it as a dependency instead
const onDelete = useCallback(
(name: string) => {
// use it here directly
deleteCountry(name);
},
// hello, memoized dependency
[deleteCountry],
);

After this step, finally, our manual memoization works.

Now, in theory, if I just remove all that manual memoization and leave the mutate fix in place, the React Compiler should be able to pick it up.

And indeed, it does! Table rows and cells donā€™t re-render anymore when I type something šŸŽ‰

6-no-more-re-renders-20240605-095209.gif

However, re-renders on ā€œaddā€ and ā€œdeleteā€ a country are still present. Letā€™s fix those as well.

Investigating ā€œaddā€ and ā€œdeleteā€ re-renders

Letā€™s take a look at the TableBody code again.

<TableBody>
{countries?.map(({ name }, index) => (
<TableRow key={index}>
<TableCell className="font-medium">
<Link href={`/country/${name.toLowerCase()}`}>
{name}
</Link>
</TableCell>
<TableCell className="text-right">
<Button
onClick={() => onDelete(name)}
variant="outline"
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>

This entire thing re-renders when I add or remove a country from the list. Letā€™s apply the same strategy again: what would I've done here if I wanted to memoize those components manually?

Itā€™s a dynamic list, so Iā€™d have to:

First, make sure that the ā€œkeyā€ property matches the country, not the position in the array. index wonā€™t do - if I remove a country from the beginning of the list, the index will change for every row below, which will force a re-render regardless of memoization. In real life, Iā€™d have to introduce some sort of id for each country. For our simplified case, letā€™s just use name and make sure weā€™re not adding duplicate names - keys should be unique.

{
countries?.map(({ name }) => (
<TableRow key={name}>...</TableRow>
));
}

Second, wrap TableRow in React.memo. Easy.

const TableRowMemo = React.memo(TableRow);

Third, memoize the children of TableRow with useMemo:

{
countries?.map(({ name }) => (
<TableRow key={name}>
... // everything inside here needs to be memoized
with useMemo
</TableRow>
));
}

which is impossible since weā€™re inside render and inside an array: hooks can only be used at the top of the component outside of the render function.

To pull this off, we need to extract the entire TableRow with its content into a component:

const CountryRow = ({ name, onDelete }) => {
return (
<TableRow>
<TableCell className="font-medium">
<Link href={`/country/${name.toLowerCase()}`}>
{name}
</Link>
</TableCell>
<TableCell className="text-right">
<Button
onClick={() => onDelete(name)}
variant="outline"
>
Delete
</Button>
</TableCell>
</TableRow>
);
};

pass data through props:

<TableBody>
{countries?.map(({ name }) => (
<CountryRow
name={name}
onDelete={onDelete}
key={name}
/>
))}
</TableBody>

and wrap CountryRow in React.memo instead. onDelete is memoized correctly - we already fixed it.

I didnā€™t even need to implement that manual memoization. As soon as I extracted those rows into a component, the Compiler immediately picked them up, and re-renders stopped šŸŽ‰. 2 : 0 in the human-against-the-machine battle.

Interestingly enough, the Compiler is able to pick up everything inside the CountryRow component but not the component itself. If I remove manual memoization but keep the key and CountryRow change, cells and rows will stop re-rendering on add/delete, but the CountryRow component itself still re-renders.

At this point, Iā€™m out of ideas on how to fix it with the Compiler, and itā€™s enough material for the article already, so Iā€™ll just let it re-render. Everything inside is memoized, so it's not that huge of a deal.

So, whatā€™s the verdict?

The Compiler performs amazingly on simple cases and simple components. Three hits out of three! However, real life is a bit more complicated.

In all three apps that I tried the Compiler on, it was able to fix only 1-2 cases of noticeable unnecessary re-renders out of 8-10 that I spotted.

However, with a bit of deductive thinking and guesswork, it looks like itā€™s possible to improve that result with minor code changes. Investigating those, however, is very non-trivial, requires a lot of creative thinking, and mastery of React algorithms and existing memoization techniques.

The changes I had to make in the existing code in order for the Compiler to behave:

  • extract mutate from the return value of the useMutation hook and use it in the code directly.
  • extract TableRow and everything inside into an isolated component.
  • change the ā€œkeyā€ from index to name.

You can check out the code before and after and play with the app yourself.

As for the assumptions that I was investigating:

Does it ā€œjust workā€? Technically, yep. You can just turn it on, and nothing seems to be broken. It wonā€™t memoize everything correctly, though, despite showing it as memoized in React Dev Tools.

Can we forget about memo, useMemo, and useCallback after installing the Compiler? Absolutely not! At least not in its current state. In fact, youā€™ll need to know them even better than itā€™s needed now and develop a sixth sense for writing components optimized for the Compiler. Or just use them to debug the re-renders you want to fix.

Thatā€™s assuming we want to fix them, of course. I suspect what will happen is this: we'll all just turn on the Compiler when itā€™s production-ready. Seeing all those ā€œmemo āœØā€ in Dev Tools will give us a sense of security, so everyone will just relax about re-renders and focus on writing features. The fact that half of the re-renders are still there no one will notice, since most of the re-renders have a negligible effect on performance anyway.

And for cases where re-renders actually have a performance impact, it will be easier to fix them with composition techniques like moving state down, passing elements as children or props, or extracting data into Context with splitted providers or any external state management tool that allows memoized selectors. And once in a blue moon - manual React.memo and useCallback.

As for those visitors from the future, Iā€™m pretty sure now that they are from a parallel universe. A marvelous place where React just happens to be written in something more structured than the notoriously flexible JavaScript, and the Compiler actually can solve 100% of the cases because of it.