In this article, we are diving into a 3D visualization and discuss how to optimize its performance step by step with React 18's concurrent rendering. Here's the sneak peek.
React 18 introduces the concurrent features. The core idea is to classify state updates into two categories:
The purpose is to create a snappier and more responsive experience for the users.
Before React 18, all updates are considered urgent, and a simple click or typing can cause the screen to freeze when there's a larger rending. So in React 18, by deferring non-urgent updates to urgent updates, users are able to receive immediate feedback from their interaction while React is working on the non-urgent updates in the background and reflect the result when ready.
According to the working group, the concurrent rendering is able to perform the following things:
We’ll use the format and methods from the articles to test the 3D rendering demo and observe the impact on the performance and user experience.
In order to create heavy computations for rendering, I built a 3D visualization to show the connections between a GitHub user and the users she's connected with on GitHub.
The technology includes:
The UI looks like this:
The components we will be discussing today includes:
You can find the repository on GitHub and the demo on Netlify.
Please note that the demo might crash due to a task timeout. The timeout is caused by the slow experimental API call to fetch the coordinates of the cities. If you'd like to interact with the demo, it's recommended to fork the repository and run the development server locally.
As we type into the search bar, you'll see an increasing amount of stars in the scene to simulate the high rendering demand on the UI. The amount of stars is
100,000 * inputValue.length
. We'll use it to stress test concurrent rendering.Let's start with our base case with the most basic implementation. The outcome looks like this:
You can see my typing on the bottom left corner of the video.
As I was typing, the search bar was frozen. It didn't display the input value until the very end. Not just the search bar, the scene was frozen too. The stars didn't get rendered until I finished typing. Ideally, we want to be able to observe immediate feedback from our typing.
Let's look into the performance together. We'll slow down the CPU by 4x and run a performance profiling.
You can see the big chunks of tasks with keypress event in the flame chart. With each keypress, React computes the next rendering for the SearchBar and the Scene. As we see in the chart, the longest task takes almost 0.8 seconds to complete. That's why the page feels so slow.
In the Home page component, the SearchBar listens to the keypress event and update the username when changes happens.
const Home: NextPage = () => {
const [username, setUsername] = useState<string>('')
const vertices = useMemo(() => computeVertices(username.length), [username])
const handleChange = (username: string) => {
setUsername(username)
}
return (
<>
<SearchBar value={username} onChange={handleChange} />
<Scene vertices={vertices} />
</>
)
}
Whenever there's a username update, React recomputes vertices by calling computeVertices() and pass the result in to the Scene component to render the stars. Since the number of vertices is 100,000 * username.length, coupling the two state updates together is a very expensive rendering cycle.
Ideally, we want to see what we typed in the search bar immediately. Knowing that updating both states in the same rendering cycle takes too long, we can simply split the two updates so that SearchBar and Scene gets rendered separately.
Let's first add a new state for the SearchBar:
type FastSearchBarProps = {
defaultValue: string
onChange: (value: string) => void
}
function FastSearchBar({ defaultValue, onChange }: FastSearchBarProps) {
// new state designated to SearchBar
const [value, setValue] = useState(defaultValue)
const handleChange = (value: string) => {
// update input value
setValue(value)
// update vertices
onChange(value)
}
return <SearchBar value={value} onChange={handleChange} />
}
const Home: NextPage = () => {
const [username, setUsername] = useState<string>('')
const vertices = useMemo(() => computeVertices(username.length), [username])
const handleChange = (username: string) => {
setUsername(username)
}
return (
<>
<FastSearchBar defaultValue={username} onChange={handleChange} />
<Scene vertices={vertices} />
</>
)
}
Now we have a value state for SearchBar and a username state for computing vertices. We'll need additional work to separate the renderings because React will batch the state updates together in the same event to avoid additional rendering. Let's take a look at some of the typical approaches.
We can simply defer the slower update by adding setTimeout to put the it in the event loop.
function FastSearchBar({ defaultValue, onChange }: FastSearchBarProps) {
const [value, setValue] = useState(defaultValue)
const handleChange = (value: string) => {
setValue(value)
setTimeout(() => {
onChange(value)
}, 0)
}
return <SearchBar value={value} onChange={handleChange} />
}
Now even with the 4x slowdown, the SearchBar looks more responsive. We also observed that the stars are getting rendered while we were typing.
If you take a look at the performance profile, you can see that we were able to separate the keypress events from the expensive tasks.
However, we noticed that the Scene was rendered twice after the last keypress event. That's because the renderings were still scheduled with outdated state when the setTimeouts were fired.
We can actually cancel the rendering schedule by implementing debouncing.
function FastSearchBar({ defaultValue, onChange }: FastSearchBarProps) {
const [value, setValue] = useState(defaultValue)
const timeout = useRef<NodeJS.Timeout>()
const handleChange = (value: string) => {
// cancel the schedule if `value` changed within 100ms.
clearTimeout(timeout.current)
setValue(value)
// schedule a new update if input value hasn't changed for 100ms.
timeout.current = setTimeout(() => {
onChange(value)
}, 100)
}
return <SearchBar value={value} onChange={handleChange} />
}
The user experience looks better now. The SearchBar stays fairly responsive with 4x slowdown and the redundant update on the Scene after the last keypress event was gone.
However, the shortcoming of debouncing in this case is that the Scene update will always be delayed by 100ms. With faster CPU, the delay is most likely redundant and it makes the page feel slow.
The page feel more responsive with either adding setTimeout or debouncing, but they introduces different problems:
setTimeout: results in additional UI updates with outdated statesdebouncing: results in artificial delay in UI updates
Let's take a look at how React 18 solves the problems with concurrent rendering:
import { startTransition } from 'react'
type FastSearchBarProps = {
defaultValue: string
onChange: (value: string) => void
}
function FastSearchBar({ defaultValue, onChange }: FastSearchBarProps) {
const [value, setValue] = useState(defaultValue)
const handleChange = (value: string) => {
setValue(value)
onChange(value)
}
return <SearchBar value={value} onChange={handleChange} />
}
const Home: NextPage = () => {
const [username, setUsername] = useState<string>('')
const vertices = useMemo(
() => computeCoordinates(username.length),
[username]
)
const handleChange = (username: string) => {
// defer username update in a less urgent UI transition
startTransition(() => {
setUsername(username)
})
}
return (
<Container>
<FastSearchBar defaultValue={username} onChange={handleChange} />
<Scene vertices={vertices} />
</Container>
)
}
We call setUsername inside of startTransition function. It tells React to defer setUsername to the other urgent updates.
As you can see, we are able to separate the rendering of SearchBar and Scene without any asynchronous functions. If we take a look at the performance profile, we can observe a few things:
The interrupting behavior is not very straight forward to observe. If you know how to analyze it from the performance profile, please feel free to reach out to me on Twitter!
Let's remove the slowdown and see the final result:
In the past, adding debouncing was the go-to solution for rendering large UI updates while keeping pages responsive. Based on this experiment, concurrent rendering makes the UI more responsive without the shortcoming of debouncing. Even for the heavy computation with at least 100,000 3D objects, concurrent rendering still helped the page to be snappy.
Here's the side-by-side comparisons:
UI Comparison with 4x CPU Throttling
Basic implementation without concurrent rendering:
Optimized implementation with concurrent rendering:
UI Comparison with No CPU Throttling
Basic implementation without concurrent rendering:
Optimized implementation with concurrent rendering:
This article is originally posted on Daw-Chih's website.