1. Introduction to Rendering and Sync
Today I want to talk to you about rendering and whether we're going to sync or not sync. We're going to have to try and work out what is going on. The first bug we're gonna look at is clicking the scroll bar causes this flash. The second issue we've got is when you're scrolling, they're completely wiping out. We've upgraded to version 18 and switch to CreateRoot, enabling concurrent rendering features and automatic batching. Let's look at the code which is causing the updates for the road, and there's nothing exciting here. But I think there's some prime suspects for this issue in version 18, concurrent rendering and automated batching.
♪♪ Today I want to talk to you about rendering and whether we're going to sync or not sync. There was a funny typo when this first got shared on Twitter, where it was actually S-I-N-K, and I was thinking, oh no, is that what they're predicting my talk to be? But let's hope not. So, to sync or not to sync? Let's dive into this. It should be hopefully be an easy question to answer.
Which of these grids is better? So, on the left, we've got Agigrid running in version 17 of React, where you've got this nice, smooth scrolling, you can click the scroll bar, and the rows just appear in the right place. But then you upgrade to version 18. So, this isn't a live version of Agigrid, this is an older version which had this issue. You click the scroll bar and the rows flash, or you start scrolling up and down and the rows blank out. And it's like, as you can tell, this is not what we want the user experience to be. So, we're going to have to try and work out what is going on.
So, as you know, I'm Steve, and I do work at Agigrid, I'm on the core grid team. And so, Agigrid is what we're trying to do is create the best JavaScript data table, whether that's in React or any of the other frameworks. It's a free tier as well as the Enterprise, and if you want to find out more, do come speak to us at the booth. We'd love to talk to you about all of it. But enough about that, let's try and clarify what we're trying to fix and how React has changed, how we can try and work out what those changes are, and then how we can fix it.
The first bug we're gonna look at is clicking the scroll bar causes this flash. Let's see, can you see that? Yes. And then, the second issue we've got is when you're scrolling, they're completely wiping out. Okay, and so, this is the only line of code which is different between those two examples. So, we've upgraded to version 18, and then we switch to CreateRoot. So, when you're using render, it's the equivalent, basically, of the rendering from version 17, but now with version 18, switch to CreateRoot, and then you're gonna start enabling all these concurrent rendering features and automatic batching. And so, that's what we're gonna do. We're gonna go bug hunting.
The first thing to do is, well, let's look at the code which is causing the updates for the road, and there's nothing exciting here. It's just state. So, when the road's changing, you scroll and you need to look at new roads. We're gonna update which roads are displayed. So, there's nothing really, I mean, unusual there, or complicated, or anything that should be going wrong. But I think there's some prime suspects for this issue in version 18, concurrent rendering and also automated batching. And what we're gonna do is, and see if it's these two features which are now interacting negatively with Aggrid's row virtualization.
2. Row Virtualization and Rendering Changes
Row virtualization is a critical performance feature for data grids. Rendering only the visible rows in the viewport prevents overloading the browser. In version 18, there is a flash issue due to changes in rendering, which now has priority-based instructions. These changes, while enabling features like useTransition, can have side effects. Check the React GitHub discussion groups for more details and context. Ivan's talk is also recommended for further understanding.
So, I guess, first of all, what is row virtualization? This is a critical performance feature for any data grid. If you want to show thousands and thousands of rows, you don't want to have to render all of that out in HTML, because you're gonna crash your browser and the experience is gonna be really, I guess, slow and difficult. But the main thing is, you don't want to overload the browser because drawing HTML is quite expensive.
So, what we're gonna do is we only render the rows that are actually visible in the viewport. So, here we go. So, a way to imagine the scrolling is we scroll, the viewport changes, and then at this point, if the browser gets to repaint, it's gonna repaint an empty grid, because the rows haven't been updated yet. And then the rows get updated after we've based on the new position of the viewport. So, I think what we can kind of imagine that's happening is that the rows aren't being updated quick enough, as well as the viewport. And so, another way we can look at this is in the DevTools profile.
So, in version 17, we'll take this benchmark of this action where we scroll and update the viewport. And the main thing to take away from this chart is that there's a single function call, and then the browser paints. So, what that represents is it's scrolling, changing where the viewport is, and rendering the rows all synchronously, and then the browser is painting. So, you don't get any kind of flash. And if we do the same thing now with version 18, it should be quite apparent where this flash is coming from, or the result of that break. So, the scroll happens, then the browser is actually getting a chance to repaint, which is why we're now getting this empty set of rows before the rows are then rendered. So, we're getting two paints instead of one. There we go. And so, this is something that we need to be aware of in version 18. So, rendering is no longer a just purely sequential set of instructions. But there's priority-based in it. And a lot of the new features in React, they require the rendering to be interruptible and support yielding to the browser. So, useTransition is a great example of this. So, it's letting you update state without blocking the UI. But these changes in the rendering have some side effects and we've run into them here in this situation. So, I'm not gonna go deeper into all of the concurrent rendering and all of that stuff. There's a lot of good information you can get on the, both on the React, GitHub discussion groups. There's so many good nuggets of information in there in the comments and in the responses. So, if you haven't looked at these discussions before, that's definitely somewhere I would say, take a look if you want to get some more low-level details or just context behind some of these changes. Another great tool is from Ivan and he's got another talk later on today. So, I'd recommend listening to him because this one definitely helps explain a lot of these concepts.
3. Using usync-externalstore for Concurrent Reads
We'll start with usync-externalstore, a way to support concurrent reads by forcing synchronous updates from a store. Visual tearing occurs when React yields during rendering, allowing the external store to update. In version 18, this can result in the display of conflicting colors. To use useSync external store, you need a subscription method and a snapshot method. The subscription method sets up a callback to notify React of store updates, while the snapshot method provides an immutable state of the store for consistent rendering.
Right, but then back to the issue, we've still got to solve this bug because we can't ship this product in the state that it was in. So, in the documentation, there's two features which contain the word sync. We could just start there.
So, there's usync-externalstore and also flush-sync. So, we'll start with usync-externalstore to start with. So, usync-externalstore, as the name suggests, is a way for you to support concurrent reads by forcing updates from a store to be synchronous. And this is all to do with visual tearing, which is the term.
So, the way to visualize this or imagine this, this is from one of the discussion groups, is that say the child knows that they're getting their color based from an external store. So, the first one gets to render and the store says you should be blue. And then React is now yielded, because this is part of the new way that we can have interruptable rendering, which has given the external store the chance to run an update and to now say that the color should be red. So, in version 17, this wasn't a problem because React was not yielding, so the store could not have updated until React had finished all of its rendering. But now, in version 18, because React is yielding during its rendering, the external store is actually getting a chance to run an update, so then, rendering is then continued and it's now picking up the color red. So, the fact that we've seen blue and red displayed at the same time is called a visual tear. So, that's something which maybe isn't happening in our situation, or maybe it is. Sorry.
So, how do you use useSync external store? Well, there's two parts to it. You need a subscription method and also a snapshot method. So, the way this works is, you need to, I need to turn that off. One second. There we go. I'm not sure which one that was. The subscription is a way of setting up a callback to say, I'm gonna take this function from React, so that's the state changed, and I'm gonna subscribe it to my store. So, when rows are changed, I can then call that function to tell React that something has updated in my store. And so, when that has then happened, React is then gonna call your snapshot method. And the snapshot method has to return an immutable state of your store so that React can then use that snapshot and complete its rendering from there. So, this is the way that instead of React keep coming back to the store during the rendering, it just returns the snapshot and then uses that consistently. So, then, even if your store updates, React will then see that it's had this state changed method called, and it will go back and get a new snapshot and restart the rendering instead of rendering halfway through and have the tearing. So, that's how this hook works. You say, using external store, pass it the subscription method and also the snapshot. And then you can use it like state, like in your controller and just map over them.
4. Rendering Synchronously and Potential API Misuse
The big change is that the rendering is now done synchronously, which fixes the first bug of flashing rows when clicking the viewport. However, the second bug of continuous flashing when scrolling remains. We're potentially using the wrong API without an external store, but let's continue for now.
But then the big change is that this is now done synchronously. So, does it work? Well, yeah, it does. It fixes our first bug. So, now you can click and the rows are not flashing as the viewport was changed. And we can validate this within the profiler. So, once again, we've got this single function, where we're scrolling the viewport and updating the rows that are rendered before the browser has a chance to paint, so which is why that flash is now gone. So, this is kind of looking good. So, we fixed the first bug, but what about the second one? When scrolling, are we gonna get this continuous flashing of rows? And also, it's worth noting here that we don't really have an external store. So, we're potentially using the wrong API here, because all we're updating is state and we're not having our rows, I guess, change as a set during the rendering. So, I think that's something which, you know, you should only use this hook in the right situations. But let's just carry on for now.
5. Debugging with React Profiler and flush-sync
When scrolling with use sync external store, there's a slight change in behavior with empty rows appearing. React's automated batching in version 18 significantly reduces the number of renders compared to version 17. However, this breaking change can be mitigated with the opt-out option called flush-sync. By using flush-sync, you can force React to flush updates within a callback, ensuring DOM updates before performing other actions. Be cautious as improper usage can impact app performance. Wrapping state updates in flush-sync can render them synchronously and improve performance. Consider using flush-sync instead of use-sync external store.
So, this is what happens when you start scrolling with use sync external store. It's slightly different behavior. I don't know if you can quite see it, but there's empty rows now. So, previously it was completely whiteing out, but now there's empty rows.
But I think the idea here is something is still not quite right. So, this is where we can reach for another tool and now to debug this and look at the React profiler. So, if we compare the profiles for scrolling, in version 17 we had like over a thousand renders, this is when you do lots of continuous scrolling. But in version 18, it's only 225. So, something is going on that's quite radically changed how React is now rendering this. And that now points us back to our other suspect feature of automated batching.
So, this is an out-of-the-box performance improvement in React 18. So, in version 17, state updates within a event handler were batched, but state updates anywhere else weren't batched together. So, in version 17, this code within the set timeout would result in two renders. So, firstly, it would update the count, React would re-ender, set the flag, it would render again. But in version 18 with automated batching, they're now batched together in the same render and it will only do it once. So, this sounds like a great performance win. And in the majority of applications and situations, this is exactly what we want. We don't want React doing wasted render cycles when it knows within this event, this is everything that has changed. But this is a breaking change, and React, they do note this, that it is a breaking change. And they expect it to be a performance improvement. But they also then handily provided an opt-out called flush-sync. So now we can go and see, well, is this actually what we need to do to solve our problem?
So flush-sync, the idea behind this is it lets you force React to flush the updates that you make within a callback, and so that you can then ensure the DOM is updated before you, I guess, perform any other actions. A common use case for this is input focusing among other things. But then there is a pitfall, so it's saying it could hurt the performance of your app, and this is because we're then opting out of that automatic batching. So whatever you run within this flush-sync callback, you're gonna force the browser to render. So if you do use it in the wrong way, you could hurt the performance of your app.
So this is a way that it can work. You wrap those state updates in flush-sync, and so this one is doing the counter and the flag within the flush-syncs, and it's gonna force React to then render them both synchronously and your batch of the two renders. So let's try this out now in our situation. So instead of the use-sync external store, we can use flush-sync, which is a lot less code.
6. Fixing Extra Updates and Resolving React 18 Issues
There's no subscription or snapshots, and we just wrap the set row controllers in flush-sync. Once again, this fixes the first bug because it's forcing that to render synchronously, but then we're still getting this same behavior, so we still haven't got to the bottom of the issue in terms of where is all these extra updates being batched together. So now that's when we can go back to the code and think, well, actually, it's not only telling which rows should be re-rendered, it's also the individual rows where we can say which cells are being rendered. So within a row component, you've got lots of different cells, and that's another part of the state. So when a row is created, it's then gets the cells that it should display and that's another set state. So if we also flush sync at that point in time, then we're saying to React, actually, I want you to render this set of rows, but for every individual row, make sure that you render all those cells and flush those out. So once we do that, we flush across the rows and the cells, we're back to the version 17 kind of performance and experience, and we've resolved the issues that were introduced with React 18, which is great.
There's no subscription or snapshots, and we just wrap the set row controllers in flush-sync. Once again, this fixes the first bug because it's forcing that to render synchronously, but then we're still getting this same behavior, so we still haven't got to the bottom of the issue in terms of where is all these extra updates being batched together, yeah, as it's still only at the 225 and it's not at version 17. So now that's when we can go back to the code and think, well, actually, it's not only telling which rows should be re-rendered, it's also the individual rows where we can say which cells are being rendered. So within a row component, you've got lots of different cells, and that's another part of the state. So when a row is created, it's then gets the cells that it should display and that's another set state. So if we also flush sync at that point in time, then we're saying to React, actually, I want you to render this set of rows, but for every individual row, make sure that you render all those cells and flush those out. So once we do that, we flush across the rows and the cells, we're back to the version 17 kind of performance and experience, and we've resolved the issues that were introduced with React 18, which is great.
7. Rendering Performance on Different Machines
Flush Sync results in a better user experience for slower machines by forcing browsers to re-render one row at a time. If the component is running on faster machines, synchronous rendering can be used for better performance. However, it's important to be cautious and consider the specific environment where the application is running.
But another thing, which is a side effect of this, is that it actually really helps on slower machines. So Flush Sync, it results in this better user experience because now we're forcing the browsers to re-render one row at a time. So if our users are on like an underpowered machine, we don't really want them to see what's happening on the right where they scroll and it's just completely blank and they're left wondering, is there any data coming or not. At least on the left, they can see, well, it is taking time, but the data is coming through. So, I mean, this is a very extreme, I've made the render. It just spins for like a couple of seconds before letting React carry on. But it's an idea where we're not always in control of where our component is running. We can't ensure that everyone has a really fast Mac, M2, laptop or anything. So it's important that as a component library, we work to make sure that the default does scale run across all the different places that it might be used. And what about faster machines? Well, if you know that Agigrid is only going to be used on faster machines, you can kind of sidestep all of this and say, yeah, I'm just going to let the rendering be synchronous. Because if your machine is fast enough to be able to keep up with the updates in a way that isn't causing it to block, then you can get really nice performance that way. But you do have to be careful that you know exactly where this application is running, if you're in control, so if you're in an internal company and you know the spec of everything, that might be suitable for you. But it's not the default because it could lead to this blocking.
Takeaways and Q&A
React 18 offers improvements, but check assumptions against your app. Initially excited about batching, but it caused flashing. useExternalStore wasn't the right fit. FlushSync fixed the issue. Consider machine range for performance. For more insights, check my talk on patterns for performance at React Advanced London. Visit Agigrid booth for more info.
So, takeaways. So React 18 does offer a lot of improvements, but you do need to check the assumptions that are being made against your own application. So for us, at first we were excited. We were thinking, OK, batching, that's going to be good. That's going to improve the performance. We're not going to render as much. But then as you can see, it led to these side effects where we're getting this flashing, which is definitely not what we wanted. So we saw we could use the useExternalStore, but it wasn't quite the right fit. And FlushSync was actually the fix that we were looking for. And then also, it's important to consider the wide range of machines that your application or component is going to be run on, and so that you can degrade performance or the rendering appropriately. So if you do want any more insights, I've done another talk on patterns for performance three weeks ago at React Advanced London. And that's another place where we look at, well, how can we avoid renders as well by doing direct style manipulation? So if you do want more insights into what we're doing at Agigrid to make sure the component is as fast as possible, that's another resource you might find interesting. But thanks for listening, and if you want to find out more about Agigrid, do come and see us at the booth. And hopefully you haven't got any too difficult questions for me. Next.
React 18 Upgrade and Debugging
From React version 18, there are other benefits besides the rendering change. Upgrading depends on the use case and application needs. Resolving issues and debugging React version 18 can take time. Use DevTools Profiler and React Profiler for insights. Compare output between versions and be methodical in trying out solutions. These are not bugs but changes unlocking new features. Consider the specific application context and the impact on performance. Upgrading may require more work, especially for scrolling through large datasets. Consider the limitations of machines and React's rendering capabilities.
The first one is, from React version 18, isn't it the same as using React version 17? Is there a benefit to using React version 18 if we use the same functions? Yes, well, I think that there's other benefits in React 18 as in just opposed to this change in the rendering, and also it will give you control, in other parts of your application to maybe use features like use deferred value or use transition. So I think you shouldn't block upgrading and thinking that it's not going to work because this is also very much depends on the use case and what your application is doing. So yeah, I wouldn't not upgrade because of this. So the answer is upgrade. Yeah. In short.
The next question is, how long did it take to resolve these issues when upgrading and what general tips or themes can be used to debug React version 18? Yeah, this took me, this took me a while. And I think that's where as well, it was the combination of using the DevTools Profiler as well as the React Profiler to get insights into, well, what's changed. And so there's definitely a lot of comparison of, you know, you could see the output in version 17. Why wasn't it doing the same thing in version 18? And running in development mode, you can then see some of the method names as well in the Profile, which can give you a bit more insight into what's going on. And then, yeah, so then it's just being very methodical with that and trying things out and recording your steps as you go, so that you're not just trying things, changing lots of things at the same time. Because I think, yeah, it's definitely change one thing at a time, see what happens, repeat the process. Sounds good.
The next one is, we're talking about bugs, but these are new features of React. Why are we talking about workaround fixes instead of addressing the underlying code issues? So I wouldn't say this is a bug in React. I would say that this is a change in behavior, which unlocks a lot of new features that React can then do and support. So there's great value in these concurrent features. I think maybe what you, where I can probably give more clarity, is that there's specific kind of things within Agigrid to enable this kind of degradation of performance on slower machines, which then is also interacting with React as well. So it's important to see in your application what is actually happening. Because if you go to the completely synchronous route with rendering, it's all fine, you know, the batching works. But it does mean there's more work for your thing to do. And scrolling through lots of rows of data is an expensive operation. Grids have lots of components in them. So it's pushing the limit of where React can do it based on what machines can also render.
Testing Performance and Throttling in Dev Tools
It's important to test performance for slow machines to ensure efficiency. Throttling in dev tools is a good first step, but it may not be completely accurate. Services like BrowserStack allow testing on physical devices virtually. Reach out on Twitter for more information.
So it's important to see in your application what is actually happening. Because if you go to the completely synchronous route with rendering, it's all fine, you know, the batching works. But it does mean there's more work for your thing to do. And scrolling through lots of rows of data is an expensive operation. Grids have lots of components in them. So it's pushing the limit of where React can do it based on what machines can also render. We love an efficiency win, so that's good.
The next one is, how do you test performance for slow machines? So there's a few different ways. There's throttling in your dev tools. I've got some reservations about whether it's completely accurate. But then there's other services out there where you can actually have a URL if you've deployed it somewhere and you can log in and run on physical devices virtually, so that's quite a useful way of then getting a real feel for it, because I'm not sure the Chrome DevTools or the other ones when you're doing that throttling is completely accurate. It's a good first step, but if you want to be 100% sure, then you might wanna just reach out for your old laptop or try it on the machines that it's gonna be run on. Do you know what those services are? Where should we find you on Twitter for a response? I think it's BrowserStack, that's one that I have used. If you don't wanna sign up for it, you get about a minute I think on each device and different versions, so you can work your way through the different versions and get an idea of the performance. Sounds good.
Alternative Solutions and Testing
There were different solutions and debugging paths tried, including the Usync external store. Finding the right balance between synchronous rendering and considering the various user environments is crucial. Testing the full life cycle of the component and accounting for different devices, like iPads, is important.
The next question, let's scroll through. Were there other solutions that were tried other than the ones presented or other debugging paths you went down? Yes, I did try a lot of different things. Yeah, so the Usync external store nearly got us there, but then it also, it relied on us kind of turning off the features within AG Grid, which is using animation frames to control when things are rendered, so there's a combination of factors going on there. But yeah, there were definitely different approaches, all had different pros and cons. And this is something which I'm actually constantly kind of reviewing as well. So we want AG Grid to be the best performance across all the different users that we've got. And you've gotta find that fine balance between, I could have made everything synchronous and just assumed everyone had a dev machine, and then we would have shipped that, and then real user use cases would have come in, saying, why is the grid not working very well? And I think it also would have caught devs out, because most devs have got more powerful machines than where their application is being used. So you've gotta test the full life cycle of where this component is used, and then make compromises about that. I love how you're talking about that. Earlier on, we were talking about how this new generation of developers actually use iPads as opposed to dev machines. And so I love the fact that you're accounting for all of that in your testing.
Comments