Skip to main content

Changes brought by Concurrent React: Distinguishing Non-Urgent Renderings

· 10 min read
Jongeun Lee

Backend.AI's MLOps platform, FastTrack, uses React 18. Let's look at the possibility of distinguishing non-urgent rendering thanks to Concurrent Renderer, a feature of React 18.

React's Concurrent feature was first publicly introduced as Async Rendering at JSConf Iceland 2018 and has been officially included in React 18 since 2022. As can be expected from this period, the Concurrent Renderer is the biggest and most important change in React 18. Although the renderer has changed, React developers can run React code made in versions prior to React 18 without major changes. Even if you don't know React's Concurrent Renderer, you can still create a UI with React. However, if you understand what this Concurrent Renderer is and when it is useful, the complex thoughts you had when developing with React will become clear and you can develop a UI that provides a better UX. This article will not discuss how Concurrent Renderer works internally. Instead, we'll take a look at what Concurrent Renderer is and how React developers can change their thinking.

To summarize the contents of this article:

Thanks to Concurrent Renderer,

  • Component rendering can be interrupted.
  • A part of the tree can be rendered where it is not visible on the screen.
  • As a result, React developers can distinguish non-urgent rendering from before.
note

"React components are abstractly pure functions."

React components actually create JavaScript functions (although creating them as classes is possible, it is not recommended in most cases). Functions create output when given input. If the input changes, a new output is created by executing the function. (Pure functions have the same output if the input is the same.) What are the inputs and outputs of a React component? The input of a React component is the property (called props in React) that the component receives as a function, and the output is the React element returned by the function. Is a hook an input? Hooks can also be considered abstractly as inputs to a function. Like React props, they are triggers that cause the tree to be rerendered when their value changes. Let's return to the discussion of rerendering.

Component rendering can be interrupted.

The core of Concurrent React is that "rendering can be interrupted." This was not possible before React 18 (except for experimental). When a React component is executed for rendering as a function, no JavaScript operations can be performed until the element is returned. This means that if the rendering function takes a long time, the event handler function that handles the user's click cannot be executed until the element is returned. But since version 18, it can be interrupted.

const A = ({ count }) => {
return (
<div>
<span>{count}</span>
<B/>
<C/>
</div>
);
};

const B = () => {
const [text, setText] = useState("");
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<D/>
</div>
);
};

const C = () => {
return <span>C</span>;
};

const D = ({ text }) => {
verySlowFunction(text); //연산이 수초 걸리는 함수라 가정합시다.
return <span>D</span>;
};

In previous versions of React 18, when A was rendered, B and C had to be rendered, and D had to be rendered for B. Until A's return value, which is a React element, is returned, no other JavaScript operation can be performed. The component tree returned by A is rendered as a single unit. It was impossible to interrupt the rendering of A once it started.

With Concurrent React, rendering can be interrupted. Why is it necessary to interrupt rendering? We can think of the following cases:

  • When the current rendering is no longer valid (stale)
    • For example, let's consider a situation where A is being rendered with a count prop of 1. At this point, a rendering request for A with a count of 2 occurs before the rendering for 1 is completed. In this case, the rendering result for 1 is no longer needed as it does not show the latest value. If the rendering for 1 can be interrupted immediately and the rendering for 2 can be started, the screen showing the latest value of 2 can be displayed to the user more quickly.
  • When there is something that needs to be processed earlier than updating the screen
    • When a user event occurs during rendering, the rendering can be interrupted and the event handler can be executed immediately in order to respond promptly.

These are all cases where improving UX by interrupting a component's rendering to perform other tasks is necessary.

Rendering a Portion of the Tree in an Invisible Area

With Concurrent React, it's possible to render only a portion of the tree that corresponds to a part of the screen that's not visible. This allows for rendering the parts of the screen that will be updated in advance while still displaying the existing rendering on the screen, and once the rendering is complete, it can be swapped in. It's natural to worry that such excessive rendering might impair usability, but with the help of the Concurrent renderer, this separate rendering can be interrupted at any time without interfering with user interaction. In fact, this feature can be used to provide a better UX.

So far, we've looked at two features of the Concurrent renderer. Now let's take a look at what "distinguishing non-urgent rendering" is and how these two features can be utilized.

Identifying non-urgent renderings

note

Examples of urgent rendering and non-urgent rendering

Let's think about the situation where a user visits a site for the first time through a browser. What is the most urgent thing in this case? It is to display the site's content on the screen as quickly as possible. If the user stays on a blank screen for a long time, they may leave without waiting. Therefore, there is no need to stop the first rendering.

Looking at the left sidebar of the homepage, we see menus corresponding to the navigation. Suppose the user is trying to press menu A but accidentally presses menu B. If it takes a long time to render the screen for menu B when the user is trying to press menu A again, the screen for menu B will be rendered before the screen for menu A, which is more urgent.

How can React developers inform React which rendering is non-urgent? You can indicate which input changes are not urgent during the changes that trigger rendering. The hooks that make it easy for developers to indicate this are useDeferredValue and useTransition. Both APIs were newly added to React 18 and have the same effect of delaying non-urgent rendering. Let's examine these two hooks one by one and understand their differences.

useDeferredValue: Distinguishing based on changing input value

This hook is used when a component that uses a specific value wants to handle the change of that value in a deferred manner.

function App() {
const [text, setText] = useState('');
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<SlowList text={text} />
</>
);
}

The example code above is one of the useDeferredValue examples on beta.reactjs.org.

In this code, text is a state, so when text changes, App is re-rendered. text is also used as an input (prop) for both <input> and <SlowList>. When text changes, the rendering of App is triggered, and during this process, input and SlowList are re-rendered using the updated text. If the rendering of SlowList takes a long time, the user's input will not be reflected until the rendering is complete, even if the user continues typing quickly.

Here, the rendering of input in input and SlowList corresponds to the user's keyboard input and is an urgent rendering, while SlowList represents the result based on user input and can be considered a less urgent rendering compared to input. In this case, using useDeferredValue, it is possible to create deferredText, which is displayed on the screen when a less urgent rendering is triggered, by using the text that triggers an urgent rendering.

function App() {
const [text, setText] = useState('');
const deferredText = useDeferredValue(text);
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<SlowList text={deferredText} />
</>
);
}

By doing this, deferredText will immediately have the previous value of text when there is a change in text. However, deferredText will also be separately rendered in a place that is not visible on the screen, and only when this separate rendering is complete, both text and deferredText will be rendered with the latest value. The change in deferredText is a non-urgent rendering and can be interrupted.

If there are continuous non-urgent rendering requests for the same component, and a previous non-urgent rendering request has not finished, it will be immediately interrupted and the rendering for the recent change will begin. For example, if the user types "ab" in an empty input box in text, a separate rendering is initiated when "a" is typed, and if "b" is pressed to make it "ab" before this separate rendering is complete, the separate rendering initiated when "a" is typed is interrupted, and rendering for "ab" is initiated.

useTransition: Distinguishing by using a function that causes a change in input

Earlier, we mentioned that both useTransition and useDeferredValue are used to distinguish between urgent and non-urgent renderings. Let's look at useTransition to understand the differences between the two.

caution

To easily understand the difference between these two, we have changed the useDeferredValue example to use useTransition. However, useTransition cannot be used with synchronous updates and input. For the reason why it cannot be used, please refer to the Trouble shooting section of the useTransition page on the React official website.

function App() {
const [text, setText] = useState("");
const [isPending, startTransition] = useTransition();
return (
<>
<button
onClick={(e) => {
startTransition(() => setText((v) => v + "a"));
}}
>
a 키
</button>
<SlowList text={text} />
</>
);
}

Differences

  • When designating a non-urgent rendering, useDeferredValue uses the value text as its trigger for rendering, while useTransition uses the function setText that triggers the change in value as its trigger. If you can't access text, it's enough to know setText.
  • There is no way to immediately display changes in text that are changed within startTransition. A separate rendering is started for the changed text, but it is only a separate rendering and the changed value cannot be known for the actual rendering for the screen. However, it can be known that a separate rendering is in progress through isPending. useTransition delays the change in state and useDeferredValue delays some rendering according to the changed state.

Similarities

  • If there are continuous non-urgent rendering requests for the same component, startTransition immediately stops the first started separate rendering if it is still in progress, and starts a separate rendering using the latest value, just like useDeferredValue.

Summary

We looked at 'distinguishing non-urgent rendering' made possible by React 18's Concurrent Renderer. By using useTransition and useDeferredValue to distinguish non-urgent rendering, even complex screen updates can be performed without compromising usability. Before React 18, a lot of development effort was required to provide this smooth usability. Through 'distinguishing non-urgent rendering' that has become easier in React 18, please provide a comfortable UX to your users.