I tried React Compiler today, and guess what... š
Nadia Makarevich
Posted on June 10, 2024
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.
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,
anduseCallback
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.
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 clinical 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.
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:
However, try enabling the āHighlight updates when components renderā setting and enjoy the light show.
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 š
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 theuseMutation
hook and use it in the code directly. - extract
TableRow
and everything inside into an isolated component. - change the ākeyā from
index
toname
.
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.
Originally published at https://www.developerway.com. The website has more articles like this š
Take a look at the Advanced React book to take your React knowledge to the next level.
Subscribe to the newsletter, connect on LinkedIn or follow on Twitter to get notified as soon as the next article comes out.
Posted on June 10, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.