Real world example: adding startTransition for slow renders #65
rickhanlonii
announced in
Deep Dive
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
In React 18 we announced a new startTransition API and shared a high-level overview of the problem it solves. In this post, we’re going to dive into a real-world example of speeding up a slow update with startTransition using a large-scale open source React app.
tl;dr
If you don't feel like reading the whole page, here's a few videos to show the difference on a fast and a slow computer.
Fast computer: No startTransition
Screen.Recording.2021-06-21.at.12.28.50.PM.mov
Fast computer: With startTransition
Screen.Recording.2021-06-22.at.3.08.39.PM.mov
Slow computer: No startTransition
Screen.Recording.2021-06-21.at.1.16.51.PM.mov
Slow computer: With startTransition
Screen.Recording.2021-06-22.at.3.06.23.PM.mov
Overview
Setup
For this example, we’ll be using the Open Targets Platform App from the thread Dan posted here. This project is a single-page application built on React using Apollo GraphQL for the data fetching and management and the Material-UI component collection.
(Big thanks to the team maintaining it for the suggestion to try it!)
To install:
git clone https://github.com/opentargets/platform-app.git
yarn
yarn start
Remember React 18 is in Alpha and this release is primarily aimed at library maintainers. You're welcome to play with it, but it's not ready for production usage, and there may still be issues and bugs!
UI overview
When you load the app on the given link, you’ll see a page with a chart of bubble relationships and a slider that changes the minimum relationship threshold. There’s no need to understand what the chart is showing, just know that there’s a slider with a value that changes a chart with a lot of bubbles when it updates:
When you update the sider, the chart will update based on the slider value:
Screen.Recording.2021-06-21.at.12.28.50.PM.mov
The Problem
This UI seems to perform well, but let’s take a closer look at the performance.
If you play with the slider, you may notice the slider stutters as it moves around. This effect is more pronounced on slower devices, so let’s slow this down 4x to see it. To do that, we can load the performance tab of the Chrome DevTools, and change the CPU setting to “4x slowdown”. With this setting, Chrome will artificially slow down the site to show what it looks like on slower devices. This tool is a great way to see how other users experience your site:
With the slowdown, you can see the slider lag as it changes:
Screen.Recording.2021-06-21.at.1.16.51.PM.mov
Let’s run a quick profile to see if we can tell what’s causing the jank. To do that, load the same profile tools as before, but this time hit “record” before moving the slider:
When you open the profile, you’ll see some large blocks of work with a mousedown event, followed by one or more mousemove events, and then a mouse up event, corresponding to the work the JavaScript thread was doing when you clicked on the slider, moved it, and then released the mouse:
If we zoom into one of the mouse moves, we can see that that event took over 1 second to complete. Waiting a full second to get feedback on an interaction like mousemove is a lot time. This is what’s making our slider feel slow:
So what’s happening?
When the mouse event is dispatched, the ClassicAssociationBubbles component calls
setMinScore
to the new value:As you can see, this single update to set the value of the slider results in an update that re-computes everything in this component, including the expensive grid rendered using the
minScore
. The two updates are tied together so that one update is very expensive.How do we fix it in React 17 and earlier?
Let’s stop for a moment and think about the problem and how it would be solved.
We have two UI components, a slider and a result from that slider. Ideally, the slider and the result would all update quickly. However, in this case, there is so much changing on the page that it’s impossible for any JavaScript function to make all of the updates fast enough for the user to not notice the lag — there’s simply too much work to do.
If we can’t update them at the same time, maybe we could split them up into two different states. Then, at least in theory, the slider state could update separately from the results. It might not get us all the way there, but let’s see how far it gets us.
Attempt #1: use two states
So let’s start by splitting up our minScore state into two different states: one to control the slider, and one to control the results:
But if you load this up in the app, you’ll see that nothing changes. This is because React batches state updates together in the same event, for performance, so that there are not two state updates right after each other. But now that they’re split, maybe we can do something different with each update.
Attempt #2: add setTimeout
One thing we can do is defer the second update somehow to allow the slider to update first.
We could do that with setTimeout:
Now you can see that the slider is more responsive, but the results continue to update long after the slider stops moving. This is because all of the state updates were still scheduled, and will be processed whenever the timeout fires:
Screen.Recording.2021-06-21.at.3.30.15.PM.mov
Attempt #3: add debouncing
Instead of scheduling all of the results to update, what if we only scheduled one update every 100ms? We can do this with a technique called debouncing:
Screen.Recording.2021-06-21.at.3.43.50.PM.mov
This is the best so far and in fact, the best you can do with React 17 and below.
But there are still a few problems with this approach.
First, if the rendering is quick, it can happen in under 100ms. That means you’re delaying the update longer than it would take to render the update to begin with:
You could try to solve this by using throttling instead debouncing, but both throttling and debouncing have another problem. There’s still a huge chunk of work for ~1 second. During this time, the slider is unresponsive again (as you can see towards the end of the video above). Here’s the profile showing the work:
In order to fix this problem, we’ll need some way to break up the large chunk of work and start working on it as soon as possible.
React 18 with startTransition
Until React 18, debouncing or throttling state updates were the best solution to this problem. With the React 18 alpha, we can use the new
startTransition
API to address the remaining problems. WithstartTransition
, we can give the user fast feedback to the slider, and begin rendering the results in the background so that they’re rendered as soon as they’re available without any long tasks lagging the page.Let’s take a look at how it works.
Upgrading to React 18
First, let’s upgrade to React 18:
When we first upgrade, there’s a warning in the console that tells us that we need to switch to createRoot:
The new root API is what will allow us to start using concurrent features like startTransition, so let’s update it:
Now we’re ready to use concurrent features!
Adding startTransition
To use
startTransition
, we can remove the debouncing logic and just wrap the second update instartTransition
:This gives us the following result (with the 4x slowdown still applied):
Screen.Recording.2021-06-21.at.5.06.16.PM.mov
Here you can see that at the beginning, there are not many updates and React renders the results immediately. But, as the slider moves more, and the results get more and more expensive to render, they start to delay. But even when they’re rendered, the slider never locks up. It always feels responsive, while the results are rendering.
And if we remove the artificial slowdown and run it at full speed on a high-end device, we can see the performance has even improved from where we started:
Screen.Recording.2021-06-22.at.9.42.18.AM.mov
How does it work?
Let’s take a look at the profile to see how this works:
Here we can see that throughout the entire interaction, React is always working. We handle mouse moves as they happen, to update the slider state, and when we’re not working on the slider we’re doing rendering work.
But what are we rendering?
We’re rendering the results! As soon as React finishes rendering the first slider update, it begins to render the transition to the results. Since this update is opted-into concurrent rendering, React will do three new things:
All together, this means that you can move the slider as much as you want and React will be able to update the slider and the results at the same time, optimized for the work the user will actually need to see.
This is concurrent rendering, and it’s only possible in React 18.
Adding visual feedback with
isPending
So far we’ve shown how to use the new features to solve existing problems using concurrent rendering, but the new features also allow us to improve the user experience with patterns that were not even possible before!
If we look closely at our solution, you can see that sometimes there’s a delay between when the Slider is changed, and when the results are shown on page (this video still has the 4x slowdown applied):
Screen.Recording.2021-06-22.at.2.55.36.PM.mov
Splitting up all of the work helped us make sure that our app was responsive to the user, but we still have to actually complete all of the work to render the content on the screen. While that’s happening, the user is left to wonder where there results are.
Before React 18, this wasn’t as much of a problem. The user knew that something was loading because the page was locked up while it rendered. With React 18, the user is able to interact with the page while we render in the background, so we need some way to show the user that the information they’re looking at is stale and being updated.
What we need is a way to show the user a pending state while React is rendering content they can’t see. React 18 provides this with the new
useTransition
Hook! This hook returns both astartTransition
function to start transitions, and anisPending
value that istrue
while a transition is rendering. This means you can tell the user that something is happening in the background, while they’re still able to interact with the page:Using the isPending flag, the results will dim if they take a little too long to update (such as with the 4x slowdown):
Screen.Recording.2021-06-22.at.3.06.23.PM.mov
This dimmed pending visual state only shows up if rendering starts to take too long. On high-end devices without an 4x slowdown, rendering is usually fast enough that we don’t show a loading state. Check out the final result:
Screen.Recording.2021-06-22.at.3.08.39.PM.mov
Here, the updates are always quick and the slider is always responsive to the user.
For an example of the final implementation, see this branch.
Beta Was this translation helpful? Give feedback.
All reactions