How React Compiler Performs on Real Code

Introduction to the Talk
Nadia Makarevich introduces herself and her background, sharing her experiences at Atlassian and working on products like Jira, and her new endeavor with Bucket UI. She describes her fascination with understanding and writing about performance re-renders in React, which she explored through articles, YouTube videos, and a book. Nadia touches on the new React compiler, originally known as React Forget, which was released in April and promises to negate the need for manual memoization.
Understanding React Re-renders
The speaker addresses the problem of re-renders in React, explaining that they can cause performance issues when components don't need to update. She details the traditional solution using memoization and describes how React Memo works to prevent unnecessary re-renders by checking if props have changed. This section sets the foundation for why React compiler was developed.
React Compiler Features
Nadia introduces React compiler, a tool by the React team that optimizes component code by making props and hooks dependencies memoized by default. She elaborates on the compiler's ability to intelligently handle code by selectively caching only necessary functions and properties.
Testing React Compiler on Examples
The speaker performs tests on synthetic examples to demonstrate the compiler's functionality. She showcases how the compiler efficiently reduces unnecessary re-renders in simplified scenarios, successfully handling memoized state and component children.
Real-world Application Testing
This chapter transitions to testing the compiler on a real-world app, providing insights into its behavior on more complex and realistic codebases. Nadia prepares to measure the initial load and interaction performance impacts using Lighthouse, outlining her method and expected outcomes.
Impact on Initial Load Performance
Nadia reports that enabling the compiler does not markedly affect the initial load performance across various pages of her app. Her measurements show that load times do not significantly worsen, indicating minimal impact from the compiler's memoization process.
Impact on Interaction Performance
She evaluates the compiler's effect on interaction performance, noting substantial improvements in some cases. However, she attributes some of the performance gains to synthetic examples rather than real-world scenarios. The results highlight that while the compiler performs well in obvious situations, manual optimizations can still be superior in intricate, realistic ones.
Limitations and Manual Optimization
Nadia discusses limitations of the compiler, citing specific cases where manual memoization still surpasses automatic functionality. Through code analysis, she uncovers issues with prop handling that the compiler could not address, reinforcing the need for developer intervention in certain contexts.
Can the Compiler Replace Manual Memoization?
Addressing the critical question of whether the React compiler eliminates the necessity for manual memoization, Nadia concludes that it largely depends on app performance requirements. While the compiler can simplify processes for many, those seeking optimal performance may continue relying on manual optimizations.
Conclusion
Nadia wraps up by reflecting on the balance between compiler convenience and the occasional need for manual precision. She acknowledges the trade-offs for developers and emphasizes how turning on the compiler could generally suffice for most applications. She invites the audience to explore more of her work on memoization and React.
Okay, now we'll find out whether this is a reality or not.
Hi, I'm Nadia, as mentioned.
Thank you for this introduction.
Let me add a little bit more.
I've been a developer for a very long time now.
I've, worked in Atlassian for a few years on a product that some people in this audience might know and love called Jira.
I work for a few years at a small startup and now I am partnering up with my friend and we're working on a thing that is called Bucket UI.
I am, a bit lazy and also I love to travel.
So a few years ago I moved from Australia to Austria just because it's easier to spell.
I am a bit of a nerd and one of my hobbies is to investigate how things work and then write the results down.
I spent the last few years investigating everything that I could about, performance, re-renders, and the React.
I wrote a bunch of articles on the topic.
I did YouTube videos.
I even published a book, half of which is dedicated to the re-renders memoization and how to control them.
And during those years, I kept seeing visitors from the future here and there who would kindly inform everyone in the conversation that memoization is not needed anymore because of React Forget now known as React compiler.
So this April, our timeline has finally caught up with theirs and a React team released the compiler to the public.
So now we actually can test it.
So of course, I had to see what the future really looks like for myself.
I tried the compiler on a few synthetic examples.
I run it on a few real apps.
I measured how it performs and what it does.
Of course, I wrote it down and now happy to share with you all what the future looks like for all of us.
So this is the what, the talk is all about.
What problem the compiler solves.
Are there any downsides to it?
How it performs on, simple examples and on real life code.
So let's start with the problem.
What exactly we trying to solve here?
This is our beautiful user interface.
When some new information comes in, we want to update it naturally.
To do this in React, we trigger what is known as a re-render.
Re-renders in React are cascading.
So every time I, every time a render of a component is triggered, it'll, trigger a render of every component inside and then every component inside this component until the end of this component tree is reached.
If those downstream re renders, affect some really heavy component that might cause performance problems, the App will become slow.
To fix this, we need to stop this chain of re-renders from happening.
So one way to do it in React is to tell this component, that it's, it doesn't change and React can skip its re-renders and re-renders of every component inside.
And of course, as always in React, there are 101 ways to do it, but one of them is memoization.
Memoization starts with React Memo.
It's a high order component given to us by React itself.
React Memo wraps our original component and render is rendered on its place.
Now when, React reaches this component in the render tree, it'll stop and check whether its props ha have changed.
If none of the props change, then a renderer will be stopped.
If even one single prop of this memoized component changes, React will continue with the re-renders as if no memoization happens.
That means that for the memo to work properly, we need to make sure that all props stay the same between re-renders.
For primitive values like this, it's quite easy.
We just need to not change them at all.
Objects, arrays and functions, however need some help.
So React uses a referential equality to check for anything between re-renders.
Something like this.
So if we declare those non primitive values inside the component, they will be recreated on every re-render.
Reference to them will change, and then memoization on the very slow component will not work.
To fix this, we have to hooks use memo and use callback.
We'll drop array and objects into use memo functions into use callback, and then those hooks will preserve the reference between re-renders and next time a re-render happens, nothing, on the memoized component will change and no re-renders.
The chain of re-renders is stopped.
This is enough of the theory for today, by the way.
And also this is a very simplified explanation, but as you can see already, it's already quite complicated.
Anyone who ever used those hooks in real life knows how hard it is to trace those changing references and how quickly our apps, our beautiful, apps turn into incomprehensible and unreadable mess of use memo and use callbacks.
So solving this is what React compiler is all about, its main promise.
The compiler is a tool developed by the React team.
It plugs into our build system, and it grabs, the original component code and tries to convert it into the code where all components, props and hooks, dependency hooks, in hooks are memoized by default.
The end result is, our normal React code that behaves as if everything is wrapped in memo, use memo or use callback, which is already quite cool.
What makes it even cooler That, this is actually not what it does.
It doesn't wrap things that blindly, it's much smarter than that.
For example, simple code like this.
Will be transformed by the compiler into something like this.
Notice how onClick, function is cached at the very bottom, but the data object that is not referenced by anything is just, inside the if statement.
It's not really cached, it's just moved there.
If, however, I reference this, data object inside onClick the, like this, the compiler will understand it and it'll just rearrange the entire code like this.
The mechanics of this fascinating.
So look at that.
However, in this case, I'm more interested in the practical applications of this engineering miracle rather than, implementation details.
More specifically, I'm interested whether our expectations from the compiler match the reality and the main questions that immediately come to mind for almost everyone are.
What about initial load of the, app when the compiler is enabled, one of the very big arguments against, memorizing everything by default is, it negatively can affect performance.
Since React now has to do so many stuff in advance way when everything is memoized, so will it actually, have a positive impact at all?
How much of a problem re-renders really are?
And the last one is, can it really catch absolutely everything?
JavaScript is notorious for being fluid and ambiguous.
Is the compiler smart enough for this?
Is it true that we'll never have to think about memoization and re-renders ever again?
Before answering those questions, I want to really quickly check the compiler on a few synthetic examples and see how it actually works.
Maybe it doesn't and we can just all go home.
Let's check this first example.
We have, a very simple component with the dialogue.
The dialogue, it has a state and a button that can open the dialogue, and we have a very slow component somewhere underneath.
Let's say it takes 500 milliseconds to re-render.
So the app is quite slow.
Normal red behavior would be to re-render everything here when the state changes.
So as a result, the model dialogue pops up with the delay because of the slow component.
If I wanted to fix it, with memoization, I would have to do this.
Just wrap the slow component in React memo.
Let's instead, get rid of this and enable the compiler and see how it goes.
This is the really beautiful app that I implemented in the dev tools.
I see lots of magical memo appearing.
That means that the compiler is actually working on the app now.
I just need to put a console log because I'm very old school into slow component and just try to trigger the dialogue and see whether the slow component re-renders or not.
Let's do this.
And as we can see in console only the button componentry vendors, the dialogue, pops up instantly.
So compiler is able to deal with this simple code that's core one for the compiler.
Let's add a little bit of props there to make it a little bit more complicated.
Standard stuff and object and a function.
So if I wanted to memoize it manually, I would have to go with the classic trio.
Use memo, use callback and memo.
Instead, let's just get rid of, this complexity.
And again, turn on the compiler and see what will happen.
And again, as you can see only the button re-renders, the slow component does not.
The dialogue again, pops up in immediately another win for the compiler.
And the final case before we go into the real app, let's try it on something more complicated.
What if a very beautiful component, renders children like this?
Off the top of your head, do you remember how to memoize this thing properly?
I will give you a second.
So who thinks it should be this?
Anyone?
Everyone's professional here because this is, not correct.
If you are not really sure, but just didn't want to out yourself.
The children here, so I will explain why it doesn't work.
The children here are just syntax sugar for a prop called children, so I can rewrite it like this and it'll work exactly the same.
The prop then contains child memo, which is an element which is, again, nothing more than syntax sugar for an object that just references the child memo component.
This object is a result of calling, reactCreateElement function, and the object itself is not memoized.
It just references the memoized function.
So what we have here is a non- memoized prop on a memoized component.
Very slow component.
Memoization will not work because of this prop.
It'll re-render with every state of the dialogue.
To memoize it correctly, we need to do this.
We need to memoize, an object that our children the same as any object.
So this pattern is the most counterintuitive pattern in React.
So a lot of people, including very senior developers, get it wrong.
So if you, got caught on this, don't worry, you're in a very good company.
But let's see whether the compiler can deal with it.
Is it smart, smarter than all of us?
Drum roll and same story as before, only button re-renders.
The dialogue pops up instantly, no slower re-renders.
That means third win for the compiler, and that's really impressive.
Time to test it on a real app because small examples like that are really easy.
Let's see how it performs in real world.
The real world looks like this.
So I have an app I've been working on for some time.
It's completely new.
It's, fully Typscript-ified, it's no legacy code on the hooks and everything is best practice more or less because I'm a bit lazy.
The app has a few pages and it total, it has sent like 15,000 lines of code.
So it's not the largest app ever, but it's good enough for a proper test.
Before turning on the compiler, I run the health check and eslint rules provided by the React team.
So those are the results.
Almost everything is compiled and zero eslint rule violations.
And finally, I haven't done any performance optimizations on the app before creating the stock.
Just exactly so I can try out the compiler on it.
So the app is as prepared to the compiler as it possibly can be.
The test conditions are, I use Lighthouse to measure the initial load and interactions performance.
I did all measurements, five times, so you actually see the average of those.
I run everything on production build and in mobile mode of lighthouse, where the CPU is slowed down four times.
So that's the setup time to see what happens.
First question to answer.
How the compile, how the compiler affects initial load.
So the first page that I measured is the initial landing page is beautiful and very, long and has lots of, lots of images.
So with the, without the compiler, the stats look like this.
It's not so bad considering it's for mobile, but probably could be better.
But I'm not, that worried about it.
I enabled the compiler on it in dev tools.
I see a bunch of memoized components, so I know it's working.
And here are the stats.
The numbers are pretty much identical, so that tells me that the compiler doesn't actually make our performance worse in any way.
At least the initial data test it.
So to be absolutely sure I run it on a few more pages and the results were more or less the same.
Some numbers will go a little bit up, some numbers will even go a little bit down.
Nothing drastic to report.
So I think I can add another win to the compiler and answer the first question that, comes to mind to everyone is, the compiler seems to have minimal impact on initial load performance.
So that's good.
Doesn't make things worse despite memoizing everything.
Second question, how much of an impact will it have on interactions performance?
Let's take a look at this page.
The app on the page itself is actually a showcase of a UI components library I'm working on.
So here is the preview of a very large component, an entire page actually inside another page.
And at the very top, I have a dark mode switcher.
So let's turn it on.
And as you can see, those green, lines mean that everything re-renders when I switch it, before the compiler, the numbers look like this, which is ouch.
That's a little bit bad.
Let's enable the compiler and see what will happen.
That is much better.
I know.
Impressive.
And actually it's way too impressive to be true, to be honest.
How exactly did that happen?
I'm not the worst developer in the world, I promise you.
So let's investigate what happens here.
The code for this entire page looks, something like this.
The thing that renders the preview of the components is just this, live provider tiny component.
It accepts a string and then it renders everything inside.
So what I have here is literally the synthetic example we looked at, the very beginning.
So a, page would state and very, slow component somewhere inside.
It is very cool that the compiler was able to pick it up.
So I will add another one to it, but also it feels a little bit like cheating because real life is usually not like that.
In real life, we will not have clear situations.
In real life we'll have a bunch of small-ish, medium-ish components, and the job for the compiler will be much harder.
So next page is slightly closer to that.
In here I have a header with stuff Filterer and a list of cards in between quick filters at the top here, they control which cards are shown.
So button, if I click on the button, it'll show me all the cards with buttons inside.
If I, click on checkbox, it'll add, cards with checkbox to the list and vice versa.
As you just saw, without memoization, everything inside this re-renders.
Performance of adding a checkbox on top of, cards with buttons.
Looks like this.
Everything is green, but there is some room, for improvement.
So let's see whether it can be better.
So enabling the compiler and checking the numbers, that is more realistic.
That is something that I will believe.
The number declined, which is good.
That's cool.
But also what is interesting is I expected much more because the list of those cards is quite long, and if all re-renders are eliminated, then adding just one or two cards from with the check boxes should have been almost instantaneous.
So something is wrong here if I switch back and check the re-render situation I will see that, all those cards still re-render, even with the compiler enabled, unfortunately.
Unfortunately.
So let's investigate what's going on here.
That is the code for this list of cards at first glance looks nothing criminal.
I have an array of items.
I map over them and render, gallery card component.
I even have correct key here.
So no arbitrary props.
Everything is coming from the data array.
The very first thing when what I do when I investigate compiler issues is try to re-implement memoization with classic tools.
So in this case, I just need to make sure that memoization for those work.
And to do that, I need to wrap the card in React memo.
And if the code is actually good, the existing cards on the list will stop re rendering, and that would mean that the compiler bailed on this component for some reason.
Which in this case actually doesn't happen, which means that the compiler is not at fault.
Something is deeply wrong with the code itself.
But again, as you can see, this is the most standard code that you can even, imagine.
I highly doubt that anyone will be able to pick up what the issues here.
So let's look deeper and try to solve it.
So as we know, even a single prop change on the memoized component will cause this component to re-render.
So there must be something wrong with the props.
All the props here are primitives except for this particular one.
It's an object with, two strings inside.
But also it's still weird because it's not like I'm recreating this object every time.
It comes from the data, that is, coming from an array, which is in turn comes from a React query function.
A React query, which is data management library.
So probably I should blame React Query for this, right?
Let's take a look.
That is the code that fetches the data.
If you never use direct query, it's just a simple library that, optimizes and to data fetching and caches it.
So I give it a key to identify what query I'm trying to extract from cache and I think function that actually returns some data for this particular key, usually simple fetch.
So if it's called with the same key, it returns the same array without re fetching everything.
So what happens here, in my case, I have a dynamic key.
That depends on the selected filters.
I have a fetch function just returns data, so when the buttons is selected, the key is this.
The library returns an array that is associated with this key.
When I turn on the check checkbox, the key turns into this.
The library returns a completely different set of data that is associated with this particular key.
It doesn't merge them together in any way to preserve the reference to those internal objects because how exactly would it know to do that?
I never told it to do it, so no, it's not the library fault either.
It does exactly what it should do and does it well.
There is no one to blame here but me for some reason.
I am the worst developer.
But, yeah, as a result, I have a different array with different references, of, to the objects inside this array rather on the screen.
And those arrays happen to have the same data most of the time.
And, those, reference those objects with the changing references are passed to the memo component.
So the, to prove whether I'm right here or not, and to see, what can, be squeezed out of performance.
All I need to do is just to flatten, spread those objects into props, basically turn them into primitives, something like this.
So in this case.
Memoized components will, accept only primitive values.
Primitive values don't actually change except for the new cards.
In, in theory, the performance should be fixed.
So a bit of refactoring and there is no re-render of cards anymore.
That's good.
That means my theory is correct.
Let's see the numbers.
That was before the compiler and after the compiler, and this one is.
Mic drop situation.
Okay.
So humans are still better than robots.
They will not take over our job anytime soon.
I'm going to add a win for me rather than the compiler.
Okay.
Those were interactions.
I think it's safe to say that, the impact of the compiler is noticeable but varied from page to page, and humans are still better at it if they really try.
So let's answer the final question now.
Is the compiler smart enough to really catch everything?
We already seen that the answer here is probably no, but to test it a little bit more I collected a list of most noticeable re-renders in my app and checked how many, re-renders are still present after I enable the compiler.
I had nine cases of noticeable re-renders, stuff like on the entire drawer, for example, re-renders, when the tops inside change, and this is their result.
Out of nine cases, I had two where everything was solved.
I have two where nothing was solved, and five of them were something in between.
Like we see, we saw in the investigation before.
The two cases where nothing was solved, were the really interesting ones because it happened because of this line and this line only.
I never even used the filtered data variable anywhere.
I just added this line and then the compiler bailed out from this component completely.
So, the fuse here is an external library, so the best guess here, what happens here is the library just does something, something inside that is so incompatible with the compiler, the compiler just cannot deal with it.
And speaking of external libraries, a few, like a month ago, React team confirmed officially that the external libraries will never be transpiled by the compiler.
So this is a feature, not a bug.
So I'm going to be mean and take the win away from the compiler here because the answer to whether the compiler can catch absolutely every re-render, is a definite no, sorry.
There will always be some external dependencies that just is incompatible with the compiler or with the memoization rules.
There will be some really weird legacy code that probably people in Jira might know that the compiler just doesn't know how to process, or the code that I had, which is not exactly wrong per se.
It's just not fine tuned for memoization.
So let's recap what we were investigating and how the compiler dealt with it.
Initial load performance.
All good.
No negative impact.
Interactions performance, they improved some of them, a little bit.
Some of them quite a lot.
Can it catch all re-renders?
No, absolutely not.
And it never will.
Does it mean that the most existential question of this year, can we forget about minimization soon?
The answer is no.
Not necessarily.
Actually, I think it depends.
If the performance of your app is not the most important thing in the world, or if it's okay-ish, could be better, but I couldn't be bothered, enabling the compiler will probably make it, a little bit better or even good enough for, at a very low cost.
The definition of good enough will always be up to you, but I suspect that for most people, turning on the compiler and just forgetting about this whole, chain of use memos and use callbacks will be good enough for the cost.
However, if good enough is not good for you and you need to squeeze every, every millisecond out of your app, welcome back to Manual memoization.
For you the answer will be no.
You cannot forget about them.
You will have to know everything that we know now about memoization.
Plus, on top of that, you would need to know what the complier is, what it does, how it does it, and how to debug it so your job will become a little bit harder.
On the plus side, it will improve your job security because if you couldn't do it, that means you are absolutely the most senior person and no will fire you.
Pros, pros and cons everywhere.
But, in reality, I think most of the people will not actually need to know all of this and they will be just satisfied with the compiler.
I hope this was good enough for the presentation.
If you want to read more stuff, about memoization and re-renders, those are the links.
If you want to check out the very early version of the components I'm working on.
You can do it here.
Please submit any feedback, but don't criticize it too much because it's very in progress.
And thank you
- memoization
- React Memo
- render
- referential equality
- useMemo
- useCallback
- onClick
- console.log
- useState
- React.createElement
- fetch API
- React Query